1static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33 collections::{HashMap, VecDeque},
34 fmt::Write,
35 fs,
36 net::{IpAddr, SocketAddr},
37 path::{Path, PathBuf},
38 process::Stdio,
39 sync::{Arc, OnceLock},
40 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46 body::Body,
47 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48 http::{header, HeaderValue, Request, StatusCode},
49 middleware::{self, Next},
50 response::{Html, IntoResponse, Response},
51 routing::{get, post},
52 Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{
59 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
60 MixedLinePolicy,
61};
62use sloc_git::ScheduleStore;
63
64#[derive(Clone)]
65pub(crate) struct CspNonce(pub(crate) String);
66
67static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
68static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
69
70use sloc_core::{
71 analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
72 CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
73 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
74};
75use sloc_report::{
76 render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
77 write_pdf_from_run, ReportDeltaContext,
78};
79const MAX_CONCURRENT_ANALYSES: usize = 4;
80
81#[cfg(target_os = "windows")]
89#[allow(clippy::upper_case_acronyms)]
90#[allow(dead_code)]
91mod win_dialog_focus {
92 #[cfg(feature = "native-dialog")]
93 use std::mem::size_of;
94
95 type HWND = *mut core::ffi::c_void;
96 type DWORD = u32;
97 type UINT = u32;
98 type BOOL = i32;
99
100 #[cfg(feature = "native-dialog")]
102 #[repr(C)]
103 #[allow(non_snake_case)]
104 struct FLASHWINFO {
105 cbSize: UINT,
106 hwnd: HWND,
107 dwFlags: DWORD,
108 uCount: UINT,
109 dwTimeout: DWORD,
110 }
111
112 #[cfg(feature = "native-dialog")]
113 const FLASHW_ALL: DWORD = 0x3;
114 #[cfg(feature = "native-dialog")]
115 const FLASHW_TIMERNOFG: DWORD = 0xC;
116
117 #[link(name = "user32")]
118 extern "system" {
119 fn GetForegroundWindow() -> HWND;
120 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
121 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
122 fn BringWindowToTop(hWnd: HWND) -> BOOL;
123 fn SetWindowPos(
124 hWnd: HWND,
125 hWndAfter: HWND,
126 x: i32,
127 y: i32,
128 cx: i32,
129 cy: i32,
130 flags: UINT,
131 ) -> BOOL;
132 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
133 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
134 #[cfg(feature = "native-dialog")]
135 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
136 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
137 fn FindWindowExW(
138 hWndParent: HWND,
139 hWndChildAfter: HWND,
140 lpszClass: *const u16,
141 lpszWindow: *const u16,
142 ) -> HWND;
143 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
147 }
148
149 #[link(name = "kernel32")]
150 extern "system" {
151 #[cfg(feature = "native-dialog")]
152 fn GetCurrentThreadId() -> DWORD;
153 }
154
155 #[link(name = "shell32")]
156 extern "system" {
157 fn ShellExecuteW(
162 hwnd: HWND,
163 lpOperation: *const u16,
164 lpFile: *const u16,
165 lpParameters: *const u16,
166 lpDirectory: *const u16,
167 nShowCmd: i32,
168 ) -> isize; }
170
171 #[cfg(feature = "native-dialog")]
176 pub fn attach_to_foreground() -> DWORD {
177 unsafe {
178 let fg_hwnd = GetForegroundWindow();
179 if fg_hwnd.is_null() {
180 return 0;
181 }
182 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
183 let my_tid = GetCurrentThreadId();
184 if fg_tid == my_tid {
185 return 0;
186 }
187 AttachThreadInput(my_tid, fg_tid, 1);
188 fg_tid
189 }
190 }
191
192 #[cfg(feature = "native-dialog")]
194 pub fn detach_from_foreground(fg_tid: DWORD) {
195 if fg_tid == 0 {
196 return;
197 }
198 unsafe {
199 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
200 }
201 }
202
203 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
204 let mut existing = std::collections::HashSet::new();
205 let mut prev: HWND = core::ptr::null_mut();
206 loop {
207 let w = FindWindowExW(
208 core::ptr::null_mut(),
209 prev,
210 class_w.as_ptr(),
211 core::ptr::null(),
212 );
213 if w.is_null() {
214 break;
215 }
216 existing.insert(w as usize);
217 prev = w;
218 }
219 existing
220 }
221
222 unsafe fn find_new_explorer_hwnd(
223 class_w: &[u16],
224 existing: &std::collections::HashSet<usize>,
225 ) -> Option<HWND> {
226 let mut prev: HWND = core::ptr::null_mut();
227 loop {
228 let w = FindWindowExW(
229 core::ptr::null_mut(),
230 prev,
231 class_w.as_ptr(),
232 core::ptr::null(),
233 );
234 if w.is_null() {
235 return None;
236 }
237 if !existing.contains(&(w as usize)) {
238 return Some(w);
239 }
240 prev = w;
241 }
242 }
243
244 unsafe fn bring_to_front(hwnd: HWND) {
245 ShowWindow(hwnd, 9);
249 SwitchToThisWindow(hwnd, 1);
250 SetForegroundWindow(hwnd);
251 BringWindowToTop(hwnd);
252 }
253
254 pub fn open_folder_foreground(path: std::path::PathBuf) {
261 std::thread::spawn(move || {
262 use std::os::windows::ffi::OsStrExt;
263
264 let op: Vec<u16> = "explore\0".encode_utf16().collect();
265 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
266 path_w.push(0);
267 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
268
269 unsafe {
270 let existing = snapshot_explorer_hwnds(&class_w);
273 let fg_hwnd = GetForegroundWindow();
274 ShellExecuteW(
276 fg_hwnd,
277 op.as_ptr(),
278 path_w.as_ptr(),
279 core::ptr::null(),
280 core::ptr::null(),
281 1,
282 );
283
284 for _ in 0..40 {
288 std::thread::sleep(std::time::Duration::from_millis(75));
289 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
290 bring_to_front(w);
291 return;
292 }
293 }
294
295 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
298 if !w.is_null() {
299 bring_to_front(w);
300 }
301 }
302 });
303 }
304
305 #[cfg(feature = "native-dialog")]
309 pub fn flash_dialog_when_ready(title: String) {
310 std::thread::spawn(move || {
311 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
312 for _ in 0..40 {
313 std::thread::sleep(std::time::Duration::from_millis(80));
314 unsafe {
315 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
316 if !hwnd.is_null() {
317 SetForegroundWindow(hwnd);
318 BringWindowToTop(hwnd);
319 #[allow(non_snake_case)]
320 FlashWindowEx(&FLASHWINFO {
321 #[allow(clippy::cast_possible_truncation)]
324 cbSize: size_of::<FLASHWINFO>() as UINT,
325 hwnd,
326 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
327 uCount: 3,
328 dwTimeout: 0,
329 });
330 break;
331 }
332 }
333 }
334 });
335 }
336}
337
338pub(crate) struct IpRateLimiter {
341 window: Duration,
342 max_requests: usize,
343 pub(crate) auth_lockout_threshold: u32,
344 auth_lockout_window: Duration,
345 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
346 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
347}
348
349impl IpRateLimiter {
350 pub(crate) fn new(
351 window: Duration,
352 max_requests: usize,
353 auth_lockout_threshold: u32,
354 auth_lockout_window: Duration,
355 ) -> Self {
356 Self {
357 window,
358 max_requests,
359 auth_lockout_threshold,
360 auth_lockout_window,
361 state: std::sync::Mutex::new(HashMap::new()),
362 auth_failures: std::sync::Mutex::new(HashMap::new()),
363 }
364 }
365
366 #[allow(clippy::significant_drop_tightening)]
369 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
370 let now = Instant::now();
371 let cutoff = now.checked_sub(self.window).unwrap_or(now);
372 let mut state = self
373 .state
374 .lock()
375 .unwrap_or_else(std::sync::PoisonError::into_inner);
376 if state.len() > 10_000 {
377 state.retain(|_, bucket| {
378 while bucket.front().is_some_and(|t| *t <= cutoff) {
379 bucket.pop_front();
380 }
381 !bucket.is_empty()
382 });
383 }
384 let bucket = state.entry(ip).or_default();
385 while bucket.front().is_some_and(|t| *t <= cutoff) {
386 bucket.pop_front();
387 }
388 if bucket.len() >= self.max_requests {
389 false
390 } else {
391 bucket.push_back(now);
392 true
393 }
394 }
395
396 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
397 let now = Instant::now();
398 let mut map = self
399 .auth_failures
400 .lock()
401 .unwrap_or_else(std::sync::PoisonError::into_inner);
402 map.entry(ip)
403 .and_modify(|e| {
404 e.0 += 1;
405 e.1 = now;
406 })
407 .or_insert_with(|| (1, now));
408 }
409
410 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
411 let mut map = self
412 .auth_failures
413 .lock()
414 .unwrap_or_else(std::sync::PoisonError::into_inner);
415 let expired = map
416 .get(&ip)
417 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
418 if expired {
419 map.remove(&ip);
420 return false;
421 }
422 map.get(&ip)
423 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
424 }
425
426 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
427 let map = self
428 .auth_failures
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 map.get(&ip).map_or(0, |e| {
432 self.auth_lockout_window
433 .checked_sub(e.1.elapsed())
434 .map_or(0, |r| r.as_secs())
435 })
436 }
437
438 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
439 tokio::spawn(async move {
440 let mut interval = tokio::time::interval(Duration::from_mins(1));
441 interval.tick().await; loop {
443 interval.tick().await;
444 let now = Instant::now();
445 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
446 {
447 let mut state = limiter
448 .state
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 state.retain(|_, bucket| {
452 while bucket.front().is_some_and(|t| *t <= cutoff) {
453 bucket.pop_front();
454 }
455 !bucket.is_empty()
456 });
457 }
458 {
459 let mut auth = limiter
460 .auth_failures
461 .lock()
462 .unwrap_or_else(std::sync::PoisonError::into_inner);
463 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
464 }
465 }
466 });
467 }
468}
469
470fn spawn_upload_staging_cleanup() {
474 tokio::spawn(async move {
475 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
476 .ok()
477 .and_then(|v| v.parse().ok())
478 .unwrap_or(4);
479 let ttl_secs = ttl_hours * 3600;
480 let mut interval = tokio::time::interval(Duration::from_hours(1));
481 interval.tick().await; loop {
483 interval.tick().await;
484 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
485 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
486 continue;
487 };
488 while let Ok(Some(entry)) = dir.next_entry().await {
489 let path = entry.path();
490 let age_secs = tokio::fs::metadata(&path)
491 .await
492 .ok()
493 .and_then(|m| m.modified().ok())
494 .and_then(|t| t.elapsed().ok())
495 .map_or(0, |d| d.as_secs());
496 if age_secs > ttl_secs {
497 tracing::debug!(
498 event = "upload_staging_cleanup",
499 path = %path.display(),
500 age_secs,
501 "removing stale upload staging directory"
502 );
503 let _ = tokio::fs::remove_dir_all(&path).await;
504 }
505 }
506 }
507 });
508}
509
510#[derive(Clone, Debug, Default)]
512struct RunResultContext {
513 prev_entry: Option<RegistryEntry>,
514 prev_scan_count: usize,
515 project_path: String,
516 cocomo_mode: String,
518 complexity_alert: u32,
520 #[allow(dead_code)]
522 exclude_duplicates: bool,
523}
524
525#[derive(Clone)]
527enum AsyncRunState {
528 Running {
529 started_at: std::time::Instant,
530 cancel_token: Arc<std::sync::atomic::AtomicBool>,
531 phase: Arc<std::sync::Mutex<String>>,
532 files_done: Arc<std::sync::atomic::AtomicUsize>,
533 files_total: Arc<std::sync::atomic::AtomicUsize>,
534 },
535 Complete {
537 run_id: String,
538 },
539 Failed {
540 message: String,
541 },
542 Cancelled,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
548struct ScanProfile {
549 id: String,
550 name: String,
551 created_at: String,
552 params: serde_json::Value,
554}
555
556#[derive(Debug, Clone, Default, Serialize, Deserialize)]
557struct ScanProfileStore {
558 profiles: Vec<ScanProfile>,
559}
560
561impl ScanProfileStore {
562 fn load(path: &std::path::Path) -> Self {
563 fs::read_to_string(path)
564 .ok()
565 .and_then(|s| serde_json::from_str(&s).ok())
566 .unwrap_or_default()
567 }
568
569 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
570 if let Some(parent) = path.parent() {
571 fs::create_dir_all(parent)?;
572 }
573 let json = serde_json::to_string_pretty(self)?;
574 fs::write(path, json)?;
575 Ok(())
576 }
577}
578
579#[derive(Clone)]
580pub(crate) struct AppState {
581 pub(crate) base_config: AppConfig,
582 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
583 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
584 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
585 pub(crate) registry_path: PathBuf,
586 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
587 pub(crate) server_mode: bool,
588 pub(crate) tls_enabled: bool,
589 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
590 pub(crate) rate_limiter: Arc<IpRateLimiter>,
591 pub(crate) trust_proxy: bool,
592 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
595 pub(crate) git_clones_dir: PathBuf,
597 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
599 pub(crate) schedules_path: PathBuf,
600 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
602 pub(crate) scan_profiles_path: PathBuf,
603 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
604 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
606 pub(crate) confluence_path: PathBuf,
607 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
609 pub(crate) watched_dirs_path: PathBuf,
610 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
612 pub(crate) cleanup_policy_path: PathBuf,
613 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
615}
616
617type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
618
619#[derive(Clone, Debug)]
622pub(crate) struct RunArtifacts {
623 output_dir: PathBuf,
624 html_path: Option<PathBuf>,
625 pdf_path: Option<PathBuf>,
626 json_path: Option<PathBuf>,
627 csv_path: Option<PathBuf>,
628 xlsx_path: Option<PathBuf>,
629 scan_config_path: Option<PathBuf>,
630 report_title: String,
631 result_context: RunResultContext,
632}
633
634#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
636 let protected = Router::new()
637 .route("/", get(splash))
638 .route("/scan-setup", get(scan_setup_handler))
639 .route("/scan", get(index))
640 .route("/analyze", post(analyze_handler))
641 .route("/preview", get(preview_handler))
642 .route("/api/suggest-coverage", get(api_suggest_coverage))
643 .route("/pick-directory", get(pick_directory_handler))
644 .route("/open-path", get(open_path_handler))
645 .route("/pick-file", get(pick_file_handler))
646 .route(
647 "/api/upload-directory",
648 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
649 )
650 .route(
651 "/api/upload-file",
652 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
653 )
654 .route(
655 "/api/upload-tarball",
656 post(upload_tarball_handler)
659 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
660 )
661 .route("/locate-report", post(locate_report_handler))
662 .route("/locate-reports-dir", post(locate_reports_dir_handler))
663 .route("/relocate-scan", post(relocate_scan_handler))
664 .route("/watched-dirs/add", post(add_watched_dir_handler))
665 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
666 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
667 .route("/view-reports", get(history_handler))
668 .route("/compare-scans", get(compare_select_handler))
669 .route("/compare", get(compare_handler))
670 .route("/multi-compare", get(multi_compare_handler))
671 .route("/images/{folder}/{file}", get(image_handler))
672 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
673 .route("/api/metrics/latest", get(api_metrics_latest_handler))
674 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
675 .route("/api/metrics/history", get(api_metrics_history_handler))
676 .route("/api/metrics/churn", get(api_metrics_churn_handler))
677 .route(
678 "/api/metrics/submodules",
679 get(api_metrics_submodules_handler),
680 )
681 .route("/api/ingest", post(api_ingest_handler))
682 .route("/api/project-history", get(project_history_handler))
683 .route("/trend-reports", get(trend_report_handler))
684 .route("/test-metrics", get(test_metrics_handler))
685 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
686 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
687 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
688 .route("/runs/result/{run_id}", get(async_run_result_handler))
689 .route("/embed/summary", get(embed_handler))
690 .route("/git-browser", get(git_browser::git_browser_handler))
692 .route("/api/git/refs", get(git_browser::api_list_refs))
693 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
694 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
695 .route(
700 "/export/pdf",
701 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
702 )
703 .route("/export-config", get(export_config_handler))
705 .route("/import-config", post(import_config_handler))
706 .route("/api/scan-profiles", get(api_list_scan_profiles))
708 .route("/api/scan-profiles", post(api_save_scan_profile))
709 .route(
710 "/api/scan-profiles/{id}",
711 axum::routing::delete(api_delete_scan_profile),
712 )
713 .route("/integrations", get(integrations::integrations_handler))
715 .route(
716 "/webhook-setup",
717 get(|| async { axum::response::Redirect::permanent("/integrations") }),
718 )
719 .route(
720 "/confluence-setup",
721 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
722 )
723 .route("/api/schedules", get(git_webhook::api_list_schedules))
724 .route("/api/schedules", post(git_webhook::api_create_schedule))
725 .route(
726 "/api/schedules",
727 axum::routing::delete(git_webhook::api_delete_schedule),
728 )
729 .route(
730 "/api/confluence/config",
731 get(confluence::api_get_confluence_config),
732 )
733 .route(
734 "/api/confluence/config",
735 post(confluence::api_save_confluence_config),
736 )
737 .route(
738 "/api/confluence/test",
739 post(confluence::api_test_confluence),
740 )
741 .route(
742 "/api/confluence/post",
743 post(confluence::api_post_to_confluence),
744 )
745 .route(
746 "/api/confluence/wiki-markup",
747 get(confluence::api_wiki_markup),
748 )
749 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
751 .route(
752 "/api/runs/{run_id}",
753 axum::routing::delete(delete_run_handler),
754 )
755 .route("/api/runs/cleanup", post(cleanup_runs_handler))
756 .route(
758 "/api/cleanup-policy",
759 get(api_get_cleanup_policy)
760 .post(api_save_cleanup_policy)
761 .delete(api_delete_cleanup_policy),
762 )
763 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
764 .route("/api-docs", get(api_docs_handler))
766 .route("/metrics", get(metrics_handler))
768 .route_layer(middleware::from_fn_with_state(
769 state.clone(),
770 auth::require_api_key,
771 ));
772
773 protected
774 .route("/healthz", get(healthz))
775 .route("/api/health", get(healthz))
776 .route("/api/version", get(api_version_handler))
777 .route("/api/openapi.yaml", get(openapi_yaml_handler))
778 .route("/llms.txt", get(llms_txt_handler))
779 .route("/llms-full.txt", get(llms_full_txt_handler))
780 .route("/badge/{metric}", get(badge_handler))
781 .route("/static/chart.js", get(chart_js_handler))
782 .route("/static/chart-report.js", get(report_chart_js_handler))
783 .route("/auth/login", get(auth::auth_login_get))
784 .route("/auth/login", post(auth::auth_login_post))
785 .route("/auth/logout", post(auth::auth_logout))
786 .route(
789 "/webhooks/github",
790 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
791 )
792 .route(
793 "/webhooks/gitlab",
794 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
795 )
796 .route(
797 "/webhooks/bitbucket",
798 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
799 )
800 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
801 .layer(middleware::from_fn(csrf_protect))
802 .layer(middleware::from_fn_with_state(
803 state.clone(),
804 add_security_headers,
805 ))
806 .layer(build_cors_layer(state.server_mode))
807 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
808 .with_state(state)
809}
810
811pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
814
815fn test_app_state(tmp_subdir: &str) -> AppState {
821 std::env::set_var("SLOC_HEADLESS", "1");
822 let tmp = std::env::temp_dir().join(tmp_subdir);
823 AppState {
824 base_config: AppConfig::default(),
825 artifacts: Arc::new(Mutex::new(HashMap::new())),
826 async_runs: Arc::new(Mutex::new(HashMap::new())),
827 registry: Arc::new(Mutex::new(ScanRegistry::default())),
828 registry_path: tmp.join("registry.json"),
829 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
830 server_mode: false,
831 tls_enabled: false,
832 api_keys: Arc::new(vec![]),
833 rate_limiter: Arc::new(IpRateLimiter::new(
834 Duration::from_mins(1),
835 600,
836 10,
837 Duration::from_hours(1),
838 )),
839 trust_proxy: false,
840 trusted_proxy_ips: vec![],
841 git_clones_dir: tmp.join("git-clones"),
842 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
843 schedules_path: tmp.join("schedules.json"),
844 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
845 scan_profiles_path: tmp.join("scan_profiles.json"),
846 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
847 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
848 confluence_path: tmp.join("confluence_config.json"),
849 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
850 watched_dirs_path: tmp.join("watched_dirs.json"),
851 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
852 cleanup_policy_path: tmp.join("cleanup_policy.json"),
853 cleanup_task_handle: Arc::new(Mutex::new(None)),
854 }
855}
856
857pub fn make_test_router() -> Router {
859 build_router(test_app_state("sloc_test"))
860}
861
862pub fn make_test_router_with_key(api_key: &str) -> Router {
864 let mut state = test_app_state("sloc_test_key");
865 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
866 build_router(state)
867}
868
869pub fn make_test_router_server_mode() -> Router {
873 let mut state = test_app_state("sloc_test_server");
874 state.server_mode = true;
875 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
876 TEST_SERVER_MODE_API_KEY.to_owned(),
877 ))]);
878 build_router(state)
879}
880
881pub fn make_test_router_exhausted_semaphore() -> Router {
884 let mut state = test_app_state("sloc_test_exhaust");
885 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
886 build_router(state)
887}
888
889pub fn make_test_router_tight_rate_limit() -> Router {
892 let mut state = test_app_state("sloc_test_rate");
893 state.rate_limiter = Arc::new(IpRateLimiter::new(
894 Duration::from_mins(1),
895 2,
896 5,
897 Duration::from_secs(5),
898 ));
899 build_router(state)
900}
901
902pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
905 let mut state = test_app_state("sloc_test_auth_lockout");
906 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
907 state.rate_limiter = Arc::new(IpRateLimiter::new(
908 Duration::from_mins(1),
909 600,
910 2, Duration::from_millis(200), ));
913 build_router(state)
914}
915
916struct RuntimeSecurityConfig {
917 api_keys: Vec<secrecy::SecretBox<String>>,
918 tls_cert: Option<String>,
919 tls_key: Option<String>,
920 tls_enabled: bool,
921 trust_proxy: bool,
922 trusted_proxy_ips: Vec<IpAddr>,
923 rate_limiter: Arc<IpRateLimiter>,
924}
925
926fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
927 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
928 .or_else(|_| std::env::var("SLOC_API_KEY"))
929 .unwrap_or_default()
930 .split(',')
931 .map(str::trim)
932 .filter(|s| !s.is_empty())
933 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
934 .collect();
935 if server_mode && api_keys.is_empty() {
936 println!(
937 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
938 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
939 );
940 }
941 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
942 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
943 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
944 if server_mode && !tls_enabled {
945 println!(
946 "WARNING: TLS is not configured. Traffic is cleartext. \
947 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
948 or terminate TLS at a reverse proxy (nginx, caddy)."
949 );
950 }
951 if server_mode {
952 println!(
953 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
954 to restrict cross-origin access (comma-separated)."
955 );
956 }
957 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
958 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
959 .unwrap_or_default()
960 .split(',')
961 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
962 .collect();
963 if trust_proxy {
964 if trusted_proxy_ips.is_empty() {
965 println!(
966 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
967 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
968 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
969 );
970 } else {
971 println!(
972 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
973 trusted_proxy_ips
974 .iter()
975 .map(std::string::ToString::to_string)
976 .collect::<Vec<_>>()
977 .join(", ")
978 );
979 }
980 } else if server_mode {
981 println!(
982 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
983 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
984 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
985 enable per-client rate limiting via X-Forwarded-For."
986 );
987 }
988 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
989 println!(
990 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
991 DISABLED for all git operations. Remove this variable before production use."
992 );
993 }
994 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
995 .ok()
996 .and_then(|v| v.parse::<u32>().ok())
997 .unwrap_or(10);
998 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
999 .ok()
1000 .and_then(|v| v.parse::<u64>().ok())
1001 .unwrap_or(3600);
1002 let default_rpm: usize = if server_mode { 120 } else { 600 };
1006 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1007 .ok()
1008 .and_then(|v| v.parse::<usize>().ok())
1009 .unwrap_or(default_rpm);
1010 let rate_limiter = Arc::new(IpRateLimiter::new(
1011 Duration::from_mins(1),
1012 rate_limit_rpm,
1013 auth_lockout_threshold,
1014 Duration::from_secs(auth_lockout_secs),
1015 ));
1016 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1017 RuntimeSecurityConfig {
1018 api_keys,
1019 tls_cert,
1020 tls_key,
1021 tls_enabled,
1022 trust_proxy,
1023 trusted_proxy_ips,
1024 rate_limiter,
1025 }
1026}
1027
1028#[allow(clippy::too_many_lines)]
1037pub async fn serve(config: AppConfig) -> Result<()> {
1038 let bind_address = config.web.bind_address.clone();
1039 let server_mode = config.web.server_mode;
1040 let output_root = resolve_output_root(None);
1041 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1043 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1044 let mut registry = ScanRegistry::load(®istry_path);
1045 registry.prune_stale();
1046 let _ = registry.save(®istry_path);
1047
1048 let sec = load_runtime_security_config(server_mode);
1049 spawn_upload_staging_cleanup();
1050
1051 let git_clones_dir = resolve_git_clones_dir(&output_root);
1052 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1053 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1054 let schedules = ScheduleStore::load(&schedules_path);
1055 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1056 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1057 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1058 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1059 |_| output_root.join("confluence_config.json"),
1060 PathBuf::from,
1061 );
1062 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1063 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1064 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1065 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1066 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1067 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1068 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1069
1070 let state = AppState {
1071 base_config: config,
1072 artifacts: Arc::new(Mutex::new(HashMap::new())),
1073 async_runs: Arc::new(Mutex::new(HashMap::new())),
1074 registry: Arc::new(Mutex::new(registry)),
1075 registry_path,
1076 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1077 server_mode,
1078 tls_enabled: sec.tls_enabled,
1079 api_keys: Arc::new(sec.api_keys),
1080 rate_limiter: sec.rate_limiter,
1081 trust_proxy: sec.trust_proxy,
1082 trusted_proxy_ips: sec.trusted_proxy_ips,
1083 git_clones_dir,
1084 schedules: Arc::new(Mutex::new(schedules)),
1085 schedules_path,
1086 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1087 scan_profiles_path,
1088 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1089 confluence: Arc::new(Mutex::new(confluence)),
1090 confluence_path,
1091 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1092 watched_dirs_path,
1093 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1094 cleanup_policy_path,
1095 cleanup_task_handle: Arc::new(Mutex::new(None)),
1096 };
1097
1098 restart_poll_schedules(&state).await;
1099 warn_insecure_gitlab_webhooks(&state).await;
1100
1101 {
1103 let enabled = state
1104 .cleanup_policy
1105 .lock()
1106 .await
1107 .policy
1108 .as_ref()
1109 .is_some_and(|p| p.enabled);
1110 if enabled {
1111 let handle = spawn_cleanup_policy_task(state.clone());
1112 *state.cleanup_task_handle.lock().await = Some(handle);
1113 }
1114 }
1115
1116 let app = build_router(state.clone());
1117
1118 let preferred: SocketAddr = bind_address
1123 .parse()
1124 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1125 let (listener, addr) = {
1126 let candidates = (0u16..=9).map(|offset| {
1127 let mut a = preferred;
1128 a.set_port(preferred.port().saturating_add(offset));
1129 a
1130 });
1131 let mut found = None;
1132 for candidate in candidates {
1133 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1134 found = Some((l, candidate));
1135 break;
1136 }
1137 }
1138 found.ok_or_else(|| {
1139 anyhow::anyhow!(
1140 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1141 bind_address,
1142 preferred.port(),
1143 preferred.port().saturating_add(9)
1144 )
1145 })?
1146 };
1147 if addr != preferred {
1148 eprintln!(
1149 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1150 using {} instead.",
1151 preferred.port(),
1152 addr.port()
1153 );
1154 }
1155
1156 if sec.tls_enabled {
1157 let cert_path = sec
1158 .tls_cert
1159 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1160 let key_path = sec
1161 .tls_key
1162 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1163 let tls_config = build_tls_config(&cert_path, &key_path)
1164 .context("failed to load TLS certificate/key")?;
1165 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1166
1167 let url = format!("https://{addr}/");
1168 println!("OxideSLOC server running at {url} (TLS)");
1169 println!("Use Ctrl+C to stop.");
1170
1171 return serve_tls(listener, app, acceptor, server_mode).await;
1172 }
1173
1174 let url = format!("http://{addr}/");
1175 log_startup_url(&url, server_mode);
1176
1177 axum::serve(
1178 listener,
1179 app.into_make_service_with_connect_info::<SocketAddr>(),
1180 )
1181 .with_graceful_shutdown(shutdown_signal(server_mode))
1182 .await
1183 .context("web server terminated unexpectedly")
1184}
1185
1186fn primary_lan_ip() -> Option<String> {
1190 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1191 socket.connect("8.8.8.8:80").ok()?;
1192 let addr = socket.local_addr().ok()?;
1193 let ip = addr.ip();
1194 if ip.is_loopback() {
1195 return None;
1196 }
1197 Some(ip.to_string())
1198}
1199
1200fn log_startup_url(url: &str, server_mode: bool) {
1202 if server_mode {
1203 println!("OxideSLOC server running at {url}");
1204 println!("Use Ctrl+C to stop.");
1205 } else {
1206 println!("OxideSLOC local web UI running at {url}");
1207 println!("Press Ctrl+C to stop the server.");
1208 let open_url = url.to_owned();
1209 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1210 }
1211}
1212
1213fn open_browser_tab(url: &str) {
1215 #[cfg(target_os = "windows")]
1220 let _ = std::process::Command::new("rundll32")
1221 .args(["url.dll,FileProtocolHandler", url])
1222 .stdout(Stdio::null())
1223 .stderr(Stdio::null())
1224 .spawn();
1225 #[cfg(target_os = "macos")]
1226 let _ = std::process::Command::new("open")
1227 .arg(url)
1228 .stdout(Stdio::null())
1229 .stderr(Stdio::null())
1230 .spawn();
1231 #[cfg(target_os = "linux")]
1232 let _ = std::process::Command::new("xdg-open")
1233 .arg(url)
1234 .stdout(Stdio::null())
1235 .stderr(Stdio::null())
1236 .spawn();
1237}
1238
1239async fn shutdown_signal(server_mode: bool) {
1241 if tokio::signal::ctrl_c().await.is_ok() {
1242 println!();
1243 if server_mode {
1244 println!("Shutting down OxideSLOC server...");
1245 } else {
1246 println!("Shutting down OxideSLOC local web UI...");
1247 }
1248 println!("Server stopped cleanly.");
1249 }
1250}
1251
1252fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1254 use rustls_pki_types::pem::PemObject;
1255 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1256
1257 let cert_bytes =
1258 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1259 let key_bytes =
1260 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1261
1262 let cert_chain: Vec<CertificateDer<'static>> =
1263 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1264 .collect::<std::result::Result<_, _>>()
1265 .context("failed to parse TLS certificates")?;
1266
1267 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1268 .context("failed to parse TLS private key")?;
1269
1270 rustls::ServerConfig::builder()
1271 .with_no_client_auth()
1272 .with_single_cert(cert_chain, key)
1273 .context("failed to build TLS server config")
1274}
1275
1276async fn serve_tls(
1278 listener: tokio::net::TcpListener,
1279 app: Router,
1280 acceptor: tokio_rustls::TlsAcceptor,
1281 server_mode: bool,
1282) -> Result<()> {
1283 use hyper_util::rt::{TokioExecutor, TokioIo};
1284 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1285 use hyper_util::service::TowerToHyperService;
1286 use tower::{Service, ServiceExt};
1287
1288 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1289
1290 loop {
1291 tokio::select! {
1292 biased;
1293 _ = tokio::signal::ctrl_c() => {
1294 println!();
1295 if server_mode {
1296 println!("Shutting down OxideSLOC server...");
1297 } else {
1298 println!("Shutting down OxideSLOC local web UI...");
1299 }
1300 println!("Server stopped cleanly.");
1301 return Ok(());
1302 }
1303 result = listener.accept() => {
1304 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1305 let acceptor = acceptor.clone();
1306 let mut factory = make_svc.clone();
1307
1308 tokio::spawn(async move {
1309 let tls = match acceptor.accept(tcp).await {
1310 Ok(s) => s,
1311 Err(e) => {
1312 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1313 return;
1314 }
1315 };
1316 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1317 Ok(f) => match Service::call(f, peer_addr).await {
1318 Ok(s) => s,
1319 Err(_) => return,
1320 },
1321 Err(_) => return,
1322 };
1323 let io = TokioIo::new(tls);
1324 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1325 .serve_connection(io, TowerToHyperService::new(svc))
1326 .await
1327 {
1328 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1329 }
1330 });
1331 }
1332 }
1333 }
1334}
1335
1336fn build_cors_layer(server_mode: bool) -> CorsLayer {
1339 if server_mode {
1340 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1341 .unwrap_or_default()
1342 .split(',')
1343 .filter(|s| !s.is_empty())
1344 .filter_map(|s| s.trim().parse().ok())
1345 .collect();
1346 if allowed.is_empty() {
1347 return CorsLayer::new();
1348 }
1349 CorsLayer::new()
1350 .allow_origin(AllowOrigin::list(allowed))
1351 .allow_methods(AllowMethods::list([
1352 axum::http::Method::GET,
1353 axum::http::Method::POST,
1354 ]))
1355 .allow_headers(AllowHeaders::list([
1356 axum::http::header::AUTHORIZATION,
1357 axum::http::header::CONTENT_TYPE,
1358 ]))
1359 } else {
1360 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1361 let s = origin.to_str().unwrap_or("");
1362 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1363 }))
1364 }
1365}
1366
1367async fn add_security_headers(
1368 State(state): State<AppState>,
1369 mut req: Request<Body>,
1370 next: Next,
1371) -> Response {
1372 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1373 req.extensions_mut().insert(CspNonce(nonce.clone()));
1374 let mut resp = next.run(req).await;
1375 inject_page_fade_into_html(&mut resp, &nonce).await;
1376 let h = resp.headers_mut();
1377 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1378 h.insert(
1379 "X-Content-Type-Options",
1380 HeaderValue::from_static("nosniff"),
1381 );
1382 h.insert(
1383 "Referrer-Policy",
1384 HeaderValue::from_static("strict-origin-when-cross-origin"),
1385 );
1386 let csp = format!(
1387 "default-src 'self'; \
1388 style-src 'self' 'unsafe-inline'; \
1389 img-src 'self' data: blob:; \
1390 script-src 'self' 'nonce-{nonce}'; \
1391 font-src 'self' data:; \
1392 object-src 'none'; \
1393 frame-ancestors 'none'"
1394 );
1395 h.insert(
1396 "Content-Security-Policy",
1397 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1398 HeaderValue::from_static(
1399 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1400 )
1401 }),
1402 );
1403 h.insert(
1404 "X-Permitted-Cross-Domain-Policies",
1405 HeaderValue::from_static("none"),
1406 );
1407 h.insert(
1408 "Permissions-Policy",
1409 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1410 );
1411 h.insert(
1412 "Cross-Origin-Opener-Policy",
1413 HeaderValue::from_static("same-origin"),
1414 );
1415 h.insert(
1416 "Cross-Origin-Resource-Policy",
1417 HeaderValue::from_static("same-origin"),
1418 );
1419 if state.tls_enabled {
1420 h.insert(
1421 "Strict-Transport-Security",
1422 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1423 );
1424 }
1425 resp
1426}
1427
1428async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1442 use axum::http::Method;
1443
1444 let is_state_changing = matches!(
1445 *req.method(),
1446 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1447 );
1448 let path = req.uri().path();
1449 let has_token_auth = req.headers().contains_key("X-API-Key")
1450 || req
1451 .headers()
1452 .get(header::AUTHORIZATION)
1453 .and_then(|v| v.to_str().ok())
1454 .is_some_and(|v| v.starts_with("Bearer "));
1455
1456 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1457 return next.run(req).await;
1458 }
1459
1460 let headers = req.headers();
1461 let header_str = |name: &header::HeaderName| {
1462 headers
1463 .get(name)
1464 .and_then(|v| v.to_str().ok())
1465 .map(str::to_owned)
1466 };
1467 let origin = header_str(&header::ORIGIN);
1468 let referer = header_str(&header::REFERER);
1469 let host = header_str(&header::HOST);
1470
1471 let authority_of = |url: &str| -> Option<String> {
1473 url.split_once("://")
1474 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1475 };
1476
1477 let source_authority = origin
1478 .as_deref()
1479 .and_then(authority_of)
1480 .or_else(|| referer.as_deref().and_then(authority_of));
1481
1482 match (source_authority, host) {
1483 (None, _) => next.run(req).await,
1485 (Some(src), Some(h)) if src == h => next.run(req).await,
1486 (Some(src), host) => {
1487 tracing::warn!(
1488 event = "csrf_rejected",
1489 path = %path,
1490 origin = %src,
1491 host = ?host,
1492 "Cross-origin state-changing request rejected (CSRF guard)"
1493 );
1494 (
1495 StatusCode::FORBIDDEN,
1496 "403 Forbidden — cross-origin request rejected\n",
1497 )
1498 .into_response()
1499 }
1500 }
1501}
1502
1503fn page_fade_html(nonce: &str) -> String {
1511 const STYLE: &str = r"<style>
1520@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1521.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1522body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1523@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1524</style>";
1525 const JS: &str = r"(function(){try{if(localStorage.getItem('sloc-dark')==='1'&&document.body)document.body.classList.add('dark-theme');}catch(e){}function leave(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;if(!a)return;if(a.target&&a.target!=='_self')return;if(a.hasAttribute('download'))return;var href=a.getAttribute('href');if(!href||href.charAt(0)==='#')return;if(/^(mailto:|tel:|javascript:)/i.test(href))return;var u;try{u=new URL(a.href,location.href);}catch(_){return;}if(u.origin!==location.origin)return;if(u.pathname===location.pathname&&u.search===location.search)return;var b=document.body;if(!b)return;b.classList.add('sloc-leaving');setTimeout(function(){b.classList.remove('sloc-leaving');},1400);}document.addEventListener('click',leave);window.addEventListener('pageshow',function(){if(document.body)document.body.classList.remove('sloc-leaving');});})();";
1534 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1535}
1536
1537fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1552 const TPL: &str = r#"<style nonce="__N__">
1553html.sloc-pending body{visibility:hidden;}
1554html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1555#rpt-loading-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .45s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
1556#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1557body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1558body.pdf-mode #rpt-loading-overlay{display:none!important;}
1559.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1560.rpt-blob-a{width:48vw;height:48vw;left:-10vw;top:-12vw;background:radial-gradient(circle,#e8932f,transparent 64%);animation:rpt-drift-a 17s ease-in-out infinite;}
1561.rpt-blob-b{width:42vw;height:42vw;right:-8vw;bottom:-10vw;background:radial-gradient(circle,#d3621a,transparent 64%);animation:rpt-drift-b 21s ease-in-out infinite;}
1562@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1563@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1564body.dark-theme .rpt-bg-blob{opacity:.36;}
1565.rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:20px;width:380px;max-width:88vw;padding:42px 50px 34px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:24px;box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 22px 64px rgba(120,64,16,.16),0 4px 16px rgba(0,0,0,.06);animation:rpt-card-in .5s cubic-bezier(.22,.68,0,1.12) both;}
1566@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1567body.dark-theme .rpt-load-card{background:linear-gradient(155deg,rgba(42,24,12,.92),rgba(28,15,6,.95));border-color:rgba(200,120,50,.16);box-shadow:0 1px 0 rgba(255,200,140,.05) inset,0 22px 64px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.35);}
1568.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1569.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1570.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1571.rpt-spinner{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,rgba(196,92,16,0) 0%,rgba(196,92,16,.18) 35%,#c45c10 100%);will-change:transform;animation:rpt-spin 1s linear infinite;-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));}
1572@keyframes rpt-spin{to{transform:rotate(360deg);}}
1573.rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
1574body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1575body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1576.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1577.rpt-load-word{background:linear-gradient(90deg,#9a7a64 0%,#c45c10 45%,#e08a3a 55%,#9a7a64 100%);background-size:220% auto;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;animation:rpt-text-shimmer 3.2s linear infinite;}
1578@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1579.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1580.rpt-dot:nth-child(2){animation-delay:.28s;}
1581.rpt-dot:nth-child(3){animation-delay:.56s;}
1582@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1583.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1584.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1585.rpt-progress-bar{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;border-radius:99px;background:linear-gradient(90deg,#e8932f,#c45c10);transition:transform .25s cubic-bezier(.4,0,.2,1);will-change:transform;}
1586body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1587@media (prefers-reduced-motion:reduce){ #rpt-loading-overlay .rpt-bg-blob,#rpt-loading-overlay .rpt-spinner,#rpt-loading-overlay .rpt-load-word,#rpt-loading-overlay .rpt-dot{animation:none!important;}}
1588</style>
1589<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1590<script nonce="__N__">document.documentElement.classList.add('sloc-pending');try{if(localStorage.getItem('sloc-dark')==='1'||localStorage.getItem('oxide-sloc-theme')==='dark')document.body.classList.add('dark-theme');}catch(e){}</script>
1591<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1592 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1593 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1594 <div class="rpt-load-card">
1595 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1596 <div class="rpt-spinner-wrap">
1597 <div class="rpt-spinner-track"></div>
1598 <div class="rpt-spinner"></div>
1599 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1600 </div>
1601 <div class="rpt-loading-text"><span class="rpt-load-word">Loading comparison</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
1602 <div class="rpt-status" id="rpt-status">__LABEL__</div>
1603 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1604 </div>
1605</div>
1606<script nonce="__N__">
1607(function(){
1608 var ov=document.getElementById('rpt-loading-overlay');
1609 var root=document.documentElement;
1610 function reveal(){root.classList.remove('sloc-pending');}
1611 if(!ov){reveal();return;}
1612 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1613 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1614 var mi=0,prog=0,done=false,start=Date.now();
1615 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1616 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1617 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1618 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1619 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1620 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1621 setProg(8);
1622 var msgTimer=setInterval(nextMsg,700);
1623 var progTimer=setInterval(function(){var cap=99;if(prog<cap){var step=(cap-prog)*0.05+0.4;setProg(Math.min(cap,prog+step));}},90);
1624 // These pages draw charts into known SVG containers that start empty and are
1625 // filled by JS once layout is available (some only after a ResizeObserver pass
1626 // post-`load`). Treat the page as ready only once every chart container present
1627 // actually has rendered content, so the overlay never lifts on a half-drawn page.
1628 function chartsRendered(){
1629 var sel=['#cmp-tl-svg','#mc-chart'];
1630 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1631 return true;
1632 }
1633 function finish(){
1634 if(done)return;done=true;
1635 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1636 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1637 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1638 reveal();
1639 requestAnimationFrame(function(){requestAnimationFrame(function(){
1640 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1641 });});
1642 }
1643 // Wait for `load` (resources + first layout), then poll until the charts have
1644 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1645 function afterLoad(){
1646 var loadAt=Date.now();
1647 (function poll(){
1648 if(done)return;
1649 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1650 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1651 return;
1652 }
1653 requestAnimationFrame(poll);
1654 })();
1655 }
1656 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1657 // Absolute safety net: never let the gate/overlay get stuck.
1658 setTimeout(function(){if(!done)finish();},HARD_CAP);
1659})();
1660</script>"#;
1661 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1662}
1663
1664fn sloc_toast_assets(nonce: &str) -> String {
1679 const TPL: &str = r##"<style nonce="__N__">
1680#sloc-toast-wrap{position:fixed;right:18px;top:18px;z-index:11000;display:flex;flex-direction:column;gap:10px;max-width:min(380px,calc(100vw - 36px));pointer-events:none;}
1681.sloc-toast{pointer-events:auto;display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:12px;background:#fcfaf7;color:#2f241c;border:1px solid #dfcfbf;box-shadow:0 12px 32px rgba(77,44,20,0.22);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;font-weight:600;line-height:1.35;opacity:0;transform:translateY(12px) scale(.96);transition:opacity .26s ease,transform .26s cubic-bezier(.22,.68,0,1.12);}
1682.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1683.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1684.sloc-toast-ico{flex:0 0 auto;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;font-style:normal;}
1685.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1686.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1687.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1688.sloc-toast-success{border-color:#bfe0cc;}
1689.sloc-toast-error{border-color:#e6b3b3;}
1690.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1691.sloc-toast-spin{flex:0 0 auto;width:18px;height:18px;border-radius:50%;border:2.5px solid rgba(196,92,16,.25);border-top-color:#c45c10;animation:sloc-toast-spin .7s linear infinite;}
1692@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1693.sloc-toast-x{flex:0 0 auto;background:none;border:none;color:inherit;opacity:.5;cursor:pointer;font-size:16px;line-height:1;padding:0 2px;margin:-1px -2px 0 2px;}
1694.sloc-toast-x:hover{opacity:1;}
1695body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1696body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1697body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1698body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1699@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1700</style>
1701<script nonce="__N__">
1702(function(){
1703 if(window.slocToast)return;
1704 function wrap(){
1705 var w=document.getElementById('sloc-toast-wrap');
1706 if(!w){w=document.createElement('div');w.id='sloc-toast-wrap';w.setAttribute('aria-live','polite');w.setAttribute('aria-atomic','false');(document.body||document.documentElement).appendChild(w);}
1707 return w;
1708 }
1709 window.slocToast=function(msg,opts){
1710 opts=opts||{};
1711 var type=opts.type||'info';
1712 var loading=type==='loading';
1713 var t=document.createElement('div');
1714 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1715 t.setAttribute('role',type==='error'?'alert':'status');
1716 var ico=loading
1717 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1718 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1719 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1720 t.querySelector('.sloc-toast-msg').textContent=String(msg);
1721 wrap().appendChild(t);
1722 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1723 var gone=false,timer=null;
1724 function close(){
1725 if(gone)return;gone=true;if(timer)clearTimeout(timer);
1726 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1727 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1728 }
1729 t.querySelector('.sloc-toast-x').addEventListener('click',close);
1730 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1731 if(ttl>0)timer=setTimeout(close,ttl);
1732 return {dismiss:close,el:t};
1733 };
1734 window.slocExportPdf=function(o){
1735 o=o||{};
1736 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1737 if(btn&&btn.disabled)return;
1738 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1739 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1740 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1741 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1742 .then(function(blob){
1743 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1744 document.body.appendChild(a);a.click();document.body.removeChild(a);
1745 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1746 load.dismiss();
1747 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1748 })
1749 .catch(function(e){
1750 load.dismiss();
1751 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1752 })
1753 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1754 };
1755})();
1756</script>"##;
1757 TPL.replace("__N__", nonce)
1758}
1759
1760async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1765 let is_html = resp
1766 .headers()
1767 .get(header::CONTENT_TYPE)
1768 .and_then(|v| v.to_str().ok())
1769 .is_some_and(|v| v.starts_with("text/html"));
1770 if !is_html {
1771 return;
1772 }
1773 let body = std::mem::replace(resp.body_mut(), Body::empty());
1774 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1775 return;
1776 };
1777 let html = match String::from_utf8(bytes.to_vec()) {
1778 Ok(s) => s,
1779 Err(e) => {
1780 *resp.body_mut() = Body::from(e.into_bytes());
1781 return;
1782 }
1783 };
1784 if html.contains("id=\"rpt-loading-overlay\"") {
1785 *resp.body_mut() = Body::from(html);
1786 return;
1787 }
1788 let insert_at = html
1792 .find("<body")
1793 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1794 .or_else(|| {
1795 let lower = html.to_ascii_lowercase();
1796 lower
1797 .find("<body")
1798 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1799 });
1800 let new_html = match insert_at {
1801 Some(at) => {
1802 let mut out = String::with_capacity(html.len() + 1024);
1803 out.push_str(&html[..at]);
1804 out.push_str(&page_fade_html(nonce));
1805 out.push_str(&html[at..]);
1806 out
1807 }
1808 None => html,
1809 };
1810 resp.headers_mut().remove(header::CONTENT_LENGTH);
1811 *resp.body_mut() = Body::from(new_html);
1812}
1813
1814async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1815 let peer_ip = req
1816 .extensions()
1817 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1818 .map(|c| c.0.ip());
1819
1820 let ip = peer_ip
1824 .and_then(|peer| {
1825 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1826 req.headers()
1827 .get("X-Forwarded-For")
1828 .and_then(|v| v.to_str().ok())
1829 .and_then(|s| s.split(',').next())
1830 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1831 } else {
1832 None
1833 }
1834 })
1835 .or(peer_ip)
1836 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1837
1838 if !state.rate_limiter.is_allowed(ip) {
1839 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1840 path = %req.uri().path(), "Rate limit exceeded");
1841 return (
1842 StatusCode::TOO_MANY_REQUESTS,
1843 [(header::RETRY_AFTER, "60")],
1844 "429 Too Many Requests\n",
1845 )
1846 .into_response();
1847 }
1848 next.run(req).await
1849}
1850
1851async fn splash(
1852 State(state): State<AppState>,
1853 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1854) -> impl IntoResponse {
1855 let lan_ip = if state.server_mode {
1856 primary_lan_ip()
1857 } else {
1858 None
1859 };
1860 let port = state
1861 .base_config
1862 .web
1863 .bind_address
1864 .rsplit(':')
1865 .next()
1866 .and_then(|p| p.parse::<u16>().ok())
1867 .unwrap_or(4317);
1868 let has_api_key = !state.api_keys.is_empty();
1869 let template = SplashTemplate {
1870 csp_nonce,
1871 server_mode: state.server_mode,
1872 lan_ip,
1873 port,
1874 version: env!("CARGO_PKG_VERSION"),
1875 has_api_key,
1876 };
1877 Html(
1878 template
1879 .render()
1880 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1881 )
1882}
1883
1884async fn index(
1885 State(state): State<AppState>,
1886 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1887 Query(query): Query<IndexQuery>,
1888) -> impl IntoResponse {
1889 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1890 let policy = query
1891 .mixed_line_policy
1892 .unwrap_or_else(|| "code_only".to_string());
1893 let behavior = query
1894 .binary_file_behavior
1895 .unwrap_or_else(|| "skip".to_string());
1896 let cfg = ScanConfig {
1897 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1898 path: query.path.unwrap_or_default(),
1899 include_globs: query.include_globs.unwrap_or_default(),
1900 exclude_globs: query.exclude_globs.unwrap_or_default(),
1901 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1902 mixed_line_policy: policy,
1903 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1904 != Some("off"),
1905 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1906 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1907 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1908 != Some("disabled"),
1909 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1910 binary_file_behavior: behavior,
1911 output_dir: query.output_dir.unwrap_or_default(),
1912 report_title: query.report_title.unwrap_or_default(),
1913 continuation_line_policy: query
1914 .continuation_line_policy
1915 .unwrap_or_else(default_each_physical_line),
1916 blank_in_block_comment_policy: query
1917 .blank_in_block_comment_policy
1918 .unwrap_or_else(default_count_as_comment),
1919 count_compiler_directives: query.count_compiler_directives.as_deref()
1920 != Some("disabled"),
1921 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
1922 style_col_threshold: query
1923 .style_col_threshold
1924 .as_deref()
1925 .and_then(|s| s.parse().ok())
1926 .unwrap_or(80),
1927 style_score_threshold: query
1928 .style_score_threshold
1929 .as_deref()
1930 .and_then(|s| s.parse().ok())
1931 .unwrap_or(0),
1932 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
1933 coverage_file: query.coverage_file.unwrap_or_default(),
1934 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
1935 complexity_alert: query
1936 .complexity_alert
1937 .as_deref()
1938 .and_then(|s| s.parse().ok())
1939 .unwrap_or(0),
1940 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
1941 activity_window: query
1942 .activity_window
1943 .as_deref()
1944 .and_then(|s| s.parse().ok())
1945 .unwrap_or(90),
1946 };
1947 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1948 } else {
1949 "{}".to_string()
1950 };
1951
1952 let git_repo = query.git_repo.unwrap_or_default();
1953 let git_ref = query.git_ref.unwrap_or_default();
1954
1955 let git_label = make_git_label(&git_repo, &git_ref);
1956 let git_output_dir = if git_label.is_empty() {
1957 String::new()
1958 } else {
1959 desktop_dir().join(&git_label).display().to_string()
1960 };
1961 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1962 let git_output_dir_json =
1963 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1964
1965 let template = IndexTemplate {
1966 version: env!("CARGO_PKG_VERSION"),
1967 prefill_json,
1968 csp_nonce,
1969 git_repo,
1970 git_ref,
1971 git_label_json,
1972 git_output_dir_json,
1973 server_mode: state.server_mode,
1974 };
1975
1976 Html(
1977 template
1978 .render()
1979 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1980 )
1981}
1982
1983async fn scan_setup_handler(
1984 State(state): State<AppState>,
1985 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1986) -> impl IntoResponse {
1987 let recent_scans_json = {
1988 let arr: Vec<serde_json::Value> = {
1989 let reg = state.registry.lock().await;
1990 reg.entries
1991 .iter()
1992 .rev()
1993 .take(6)
1994 .map(|e| {
1995 let run_dir = e
1996 .html_path
1997 .as_ref()
1998 .or(e.json_path.as_ref())
1999 .and_then(|p| p.parent().map(PathBuf::from));
2000 let config_val: Option<serde_json::Value> = run_dir
2001 .and_then(|d| find_scan_config_in_dir(&d))
2002 .and_then(|p| fs::read_to_string(&p).ok())
2003 .and_then(|s| serde_json::from_str(&s).ok());
2004 serde_json::json!({
2005 "project_label": e.project_label,
2006 "timestamp": fmt_la_time(e.timestamp_utc),
2007 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2008 "config": config_val,
2009 })
2010 })
2011 .collect()
2012 };
2013 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2014 };
2015
2016 let template = ScanSetupTemplate {
2017 version: env!("CARGO_PKG_VERSION"),
2018 recent_scans_json,
2019 csp_nonce,
2020 };
2021 Html(
2022 template
2023 .render()
2024 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2025 )
2026}
2027
2028async fn healthz() -> &'static str {
2029 "ok"
2030}
2031
2032async fn api_version_handler() -> impl IntoResponse {
2033 axum::Json(serde_json::json!({
2034 "name": "oxide-sloc",
2035 "version": env!("CARGO_PKG_VERSION"),
2036 }))
2037}
2038
2039fn prom_runs_total() -> &'static prometheus::IntCounter {
2042 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2043 COUNTER.get_or_init(|| {
2044 prometheus::register_int_counter!(
2045 "oxide_sloc_runs_total",
2046 "Total number of completed analysis runs"
2047 )
2048 .expect("failed to register oxide_sloc_runs_total counter")
2049 })
2050}
2051
2052async fn metrics_handler() -> impl IntoResponse {
2053 use prometheus::Encoder as _;
2054 let mut buf = Vec::new();
2055 let encoder = prometheus::TextEncoder::new();
2056 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2057 (
2058 [(
2059 axum::http::header::CONTENT_TYPE,
2060 "text/plain; version=0.0.4; charset=utf-8",
2061 )],
2062 buf,
2063 )
2064}
2065
2066static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2067
2068async fn openapi_yaml_handler() -> impl IntoResponse {
2069 (
2070 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2071 OPENAPI_YAML,
2072 )
2073}
2074
2075static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2076static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2077
2078async fn llms_txt_handler() -> impl IntoResponse {
2079 (
2080 [
2081 (
2082 axum::http::header::CONTENT_TYPE,
2083 "text/plain; charset=utf-8",
2084 ),
2085 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2086 ],
2087 LLMS_TXT,
2088 )
2089}
2090
2091async fn llms_full_txt_handler() -> impl IntoResponse {
2092 (
2093 [
2094 (
2095 axum::http::header::CONTENT_TYPE,
2096 "text/plain; charset=utf-8",
2097 ),
2098 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2099 ],
2100 LLMS_FULL_TXT,
2101 )
2102}
2103
2104async fn api_docs_handler(
2105 State(state): State<AppState>,
2106 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2107) -> impl IntoResponse {
2108 let has_api_key = !state.api_keys.is_empty();
2109 Html(
2110 ApiDocsTemplate {
2111 has_api_key,
2112 csp_nonce,
2113 version: env!("CARGO_PKG_VERSION"),
2114 }
2115 .render()
2116 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2117 )
2118}
2119
2120async fn chart_js_handler() -> impl IntoResponse {
2121 (
2122 [
2123 (
2124 header::CONTENT_TYPE,
2125 "application/javascript; charset=utf-8",
2126 ),
2127 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2128 ],
2129 CHART_JS,
2130 )
2131}
2132
2133async fn report_chart_js_handler() -> impl IntoResponse {
2134 (
2135 [
2136 (
2137 header::CONTENT_TYPE,
2138 "application/javascript; charset=utf-8",
2139 ),
2140 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2141 ],
2142 REPORT_CHART_JS,
2143 )
2144}
2145
2146#[derive(Debug, Deserialize)]
2147struct AnalyzeForm {
2148 path: String,
2149 git_repo: Option<String>,
2150 git_ref: Option<String>,
2151 mixed_line_policy: Option<MixedLinePolicy>,
2152 python_docstrings_as_comments: Option<String>,
2153 generated_file_detection: Option<String>,
2154 minified_file_detection: Option<String>,
2155 vendor_directory_detection: Option<String>,
2156 include_lockfiles: Option<String>,
2157 binary_file_behavior: Option<BinaryFileBehavior>,
2158 output_dir: Option<String>,
2159 report_title: Option<String>,
2160 report_header_footer: Option<String>,
2161 include_globs: Option<String>,
2162 exclude_globs: Option<String>,
2163 submodule_breakdown: Option<String>,
2164 coverage_file: Option<String>,
2165 continuation_line_policy: Option<ContinuationLinePolicy>,
2166 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2167 count_compiler_directives: Option<String>,
2168 style_col_threshold: Option<String>,
2169 style_analysis_enabled: Option<String>,
2170 style_score_threshold: Option<String>,
2171 style_lang_scope: Option<String>,
2172 cocomo_mode: Option<String>,
2174 complexity_alert: Option<String>,
2176 exclude_duplicates: Option<String>,
2178 activity_window: Option<String>,
2180}
2181
2182#[allow(clippy::struct_excessive_bools)]
2183#[derive(Debug, Serialize, Deserialize, Clone)]
2184struct ScanConfig {
2185 oxide_sloc_version: String,
2186 path: String,
2187 include_globs: String,
2188 exclude_globs: String,
2189 submodule_breakdown: bool,
2190 mixed_line_policy: String,
2191 python_docstrings_as_comments: bool,
2192 generated_file_detection: bool,
2193 minified_file_detection: bool,
2194 vendor_directory_detection: bool,
2195 include_lockfiles: bool,
2196 binary_file_behavior: String,
2197 output_dir: String,
2198 report_title: String,
2199 #[serde(default = "default_each_physical_line")]
2201 continuation_line_policy: String,
2202 #[serde(default = "default_count_as_comment")]
2203 blank_in_block_comment_policy: String,
2204 #[serde(default = "default_true_bool")]
2205 count_compiler_directives: bool,
2206 #[serde(default = "default_true_bool")]
2207 style_analysis_enabled: bool,
2208 #[serde(default = "default_style_col_threshold")]
2209 style_col_threshold: u16,
2210 #[serde(default)]
2211 style_score_threshold: u8,
2212 #[serde(default = "default_all_scope")]
2213 style_lang_scope: String,
2214 #[serde(default)]
2215 coverage_file: String,
2216 #[serde(default = "default_organic")]
2217 cocomo_mode: String,
2218 #[serde(default)]
2219 complexity_alert: u32,
2220 #[serde(default)]
2221 exclude_duplicates: bool,
2222 #[serde(default = "default_activity_window")]
2224 activity_window: u32,
2225}
2226
2227const fn default_activity_window() -> u32 {
2228 90
2229}
2230
2231fn default_each_physical_line() -> String {
2232 "each_physical_line".to_string()
2233}
2234fn default_count_as_comment() -> String {
2235 "count_as_comment".to_string()
2236}
2237const fn default_true_bool() -> bool {
2238 true
2239}
2240const fn default_style_col_threshold() -> u16 {
2241 80
2242}
2243fn default_all_scope() -> String {
2244 "all".to_string()
2245}
2246fn default_organic() -> String {
2247 "organic".to_string()
2248}
2249
2250#[derive(Debug, Deserialize, Default)]
2251struct IndexQuery {
2252 path: Option<String>,
2253 include_globs: Option<String>,
2254 exclude_globs: Option<String>,
2255 submodule_breakdown: Option<String>,
2256 mixed_line_policy: Option<String>,
2257 python_docstrings_as_comments: Option<String>,
2258 generated_file_detection: Option<String>,
2259 minified_file_detection: Option<String>,
2260 vendor_directory_detection: Option<String>,
2261 include_lockfiles: Option<String>,
2262 binary_file_behavior: Option<String>,
2263 output_dir: Option<String>,
2264 report_title: Option<String>,
2265 prefilled: Option<String>,
2266 git_repo: Option<String>,
2267 git_ref: Option<String>,
2268 continuation_line_policy: Option<String>,
2270 blank_in_block_comment_policy: Option<String>,
2271 count_compiler_directives: Option<String>,
2272 style_analysis_enabled: Option<String>,
2273 style_col_threshold: Option<String>,
2274 style_score_threshold: Option<String>,
2275 style_lang_scope: Option<String>,
2276 coverage_file: Option<String>,
2277 cocomo_mode: Option<String>,
2278 complexity_alert: Option<String>,
2279 exclude_duplicates: Option<String>,
2280 activity_window: Option<String>,
2281}
2282
2283#[derive(Debug, Deserialize)]
2284struct PreviewQuery {
2285 path: Option<String>,
2286 include_globs: Option<String>,
2287 exclude_globs: Option<String>,
2288}
2289
2290#[cfg(feature = "native-dialog")]
2291#[derive(Debug, Deserialize)]
2292struct PickDirectoryQuery {
2293 kind: Option<String>,
2294 current: Option<String>,
2295}
2296
2297#[cfg(not(feature = "native-dialog"))]
2298#[derive(Debug, Deserialize)]
2299struct PickDirectoryQuery {}
2300
2301#[derive(Debug, Deserialize, Default)]
2302struct ArtifactQuery {
2303 download: Option<String>,
2304}
2305
2306#[cfg(feature = "native-dialog")]
2307#[derive(Debug, Serialize)]
2308struct PickDirectoryResponse {
2309 selected_path: Option<String>,
2310 cancelled: bool,
2311}
2312
2313#[cfg(feature = "native-dialog")]
2314async fn pick_directory_handler(
2315 State(state): State<AppState>,
2316 Query(query): Query<PickDirectoryQuery>,
2317) -> Response {
2318 if state.server_mode {
2319 return StatusCode::NOT_FOUND.into_response();
2320 }
2321 if std::env::var("SLOC_HEADLESS").is_ok() {
2323 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2324 .into_response();
2325 }
2326
2327 let is_coverage = query.kind.as_deref() == Some("coverage");
2328 let title = match query.kind.as_deref() {
2329 Some("output") => "Select output directory",
2330 Some("reports") => "Select folder containing saved reports",
2331 Some("coverage") => "Select LCOV coverage file",
2332 _ => "Select project directory",
2333 }
2334 .to_owned();
2335 let current = query.current.clone();
2336
2337 let picked = tokio::task::spawn_blocking(move || {
2338 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2341 let fg_tid = win_dialog_focus::attach_to_foreground();
2342 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2343 win_dialog_focus::flash_dialog_when_ready(title.clone());
2344
2345 let mut dialog = rfd::FileDialog::new().set_title(&title);
2346 if let Some(current) = current.as_deref() {
2347 let resolved = resolve_input_path(current);
2348 let seed = if resolved.is_dir() {
2349 Some(resolved)
2350 } else {
2351 resolved.parent().map(Path::to_path_buf)
2352 };
2353 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2354 dialog = dialog.set_directory(seed_dir);
2355 }
2356 }
2357 let result = if is_coverage {
2358 dialog
2359 .add_filter(
2360 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2361 &["info", "lcov", "xml", "json"],
2362 )
2363 .pick_file()
2364 } else {
2365 dialog.pick_folder()
2366 };
2367
2368 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2369 win_dialog_focus::detach_from_foreground(fg_tid);
2370
2371 result
2372 })
2373 .await
2374 .unwrap_or(None);
2375
2376 Json(PickDirectoryResponse {
2377 selected_path: picked.as_ref().map(|p| display_path(p)),
2378 cancelled: picked.is_none(),
2379 })
2380 .into_response()
2381}
2382
2383#[cfg(not(feature = "native-dialog"))]
2384async fn pick_directory_handler(
2385 State(_state): State<AppState>,
2386 Query(_query): Query<PickDirectoryQuery>,
2387) -> Response {
2388 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2389}
2390
2391#[cfg(feature = "native-dialog")]
2392async fn pick_file_handler(State(state): State<AppState>) -> Response {
2393 if state.server_mode {
2394 return StatusCode::NOT_FOUND.into_response();
2395 }
2396 if std::env::var("SLOC_HEADLESS").is_ok() {
2397 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2398 .into_response();
2399 }
2400 let picked = tokio::task::spawn_blocking(|| {
2401 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2402 let fg_tid = win_dialog_focus::attach_to_foreground();
2403 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2404 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2405
2406 let result = rfd::FileDialog::new()
2407 .set_title("Select HTML report")
2408 .add_filter("HTML report", &["html"])
2409 .pick_file();
2410
2411 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2412 win_dialog_focus::detach_from_foreground(fg_tid);
2413
2414 result
2415 })
2416 .await
2417 .unwrap_or(None);
2418 Json(PickDirectoryResponse {
2419 selected_path: picked.as_ref().map(|p| display_path(p)),
2420 cancelled: picked.is_none(),
2421 })
2422 .into_response()
2423}
2424
2425#[cfg(not(feature = "native-dialog"))]
2426async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2427 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2428}
2429
2430fn is_upload_tmp_path(path: &Path) -> bool {
2435 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2436 path.starts_with(&upload_root)
2437}
2438
2439fn is_sample_path(path: &Path) -> bool {
2442 let root = workspace_root();
2443 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2444}
2445
2446fn upload_base_dir() -> PathBuf {
2448 std::env::temp_dir().join("oxide-sloc-uploads")
2449}
2450
2451fn upload_staging_path(id: &str) -> PathBuf {
2453 upload_base_dir().join(id)
2454}
2455
2456#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2460 const MAX_FILES: usize = 50_000;
2461 if body.files.is_empty() {
2462 return Err((
2463 StatusCode::BAD_REQUEST,
2464 Json(serde_json::json!({"error": "No files received"})),
2465 )
2466 .into_response());
2467 }
2468 if body.files.len() > MAX_FILES {
2469 return Err((
2470 StatusCode::PAYLOAD_TOO_LARGE,
2471 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2472 )
2473 .into_response());
2474 }
2475 Ok(())
2476}
2477
2478fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2481 match id {
2482 Some(id)
2483 if !id.is_empty()
2484 && id.len() <= 36
2485 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2486 {
2487 (id.to_string(), upload_staging_path(id))
2488 }
2489 _ => {
2490 let new_id = uuid::Uuid::new_v4().to_string();
2491 let staging = upload_staging_path(&new_id);
2492 (new_id, staging)
2493 }
2494 }
2495}
2496
2497#[allow(clippy::result_large_err)]
2502async fn stage_decoded_entry(
2503 entry: &UploadedFile,
2504 staging: &Path,
2505 total_bytes: &mut usize,
2506 project_root: &mut Option<PathBuf>,
2507) -> Result<(), Response> {
2508 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2509
2510 let Ok(data) = base64::Engine::decode(
2511 &base64::engine::general_purpose::STANDARD,
2512 entry.content.as_bytes(),
2513 ) else {
2514 return Ok(());
2515 };
2516
2517 *total_bytes += data.len();
2518 if *total_bytes > MAX_TOTAL_BYTES {
2519 return Err((
2520 StatusCode::PAYLOAD_TOO_LARGE,
2521 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2522 )
2523 .into_response());
2524 }
2525
2526 let rel = std::path::Path::new(&entry.path);
2527 if project_root.is_none() {
2528 if let Some(first) = rel.components().next() {
2529 *project_root = Some(staging.join(first.as_os_str()));
2530 }
2531 }
2532
2533 let dest = staging.join(rel);
2534 if let Some(parent) = dest.parent() {
2535 if tokio::fs::create_dir_all(parent).await.is_err() {
2536 return Err((
2537 StatusCode::INTERNAL_SERVER_ERROR,
2538 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2539 )
2540 .into_response());
2541 }
2542 }
2543
2544 if tokio::fs::write(&dest, &data).await.is_err() {
2545 return Err((
2546 StatusCode::INTERNAL_SERVER_ERROR,
2547 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2548 )
2549 .into_response());
2550 }
2551
2552 Ok(())
2553}
2554
2555async fn write_upload_files(
2559 files: &[UploadedFile],
2560 staging: &Path,
2561 upload_id: &str,
2562) -> Result<(usize, Option<PathBuf>), Response> {
2563 let mut total_bytes: usize = 0;
2564 let mut project_root: Option<PathBuf> = None;
2565
2566 for entry in files {
2567 let rel = std::path::Path::new(&entry.path);
2568 if rel
2569 .components()
2570 .any(|c| matches!(c, std::path::Component::ParentDir))
2571 {
2572 let _ = tokio::fs::remove_dir_all(staging).await;
2574 tracing::warn!(
2575 event = "upload_path_traversal",
2576 upload_id = %upload_id,
2577 path = %entry.path,
2578 "Upload rejected: path traversal component detected"
2579 );
2580 return Err((
2581 StatusCode::BAD_REQUEST,
2582 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2583 )
2584 .into_response());
2585 }
2586
2587 if let Err(resp) =
2588 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2589 {
2590 let _ = tokio::fs::remove_dir_all(staging).await;
2591 return Err(resp);
2592 }
2593 }
2594
2595 Ok((files.len(), project_root))
2596}
2597
2598fn parse_tarball_size_caps() -> (u64, u64) {
2601 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2602 .ok()
2603 .and_then(|v| v.parse().ok())
2604 .unwrap_or(2048_u64)
2605 * 1024
2606 * 1024;
2607 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2608 .ok()
2609 .and_then(|v| v.parse().ok())
2610 .unwrap_or(10_240_u64)
2611 * 1024
2612 * 1024;
2613 (compressed, decompressed)
2614}
2615
2616fn tarball_http_body_limit_bytes() -> usize {
2620 std::env::var("SLOC_MAX_TARBALL_MB")
2621 .ok()
2622 .and_then(|v| v.parse::<usize>().ok())
2623 .unwrap_or(2048)
2624 .saturating_mul(1024 * 1024)
2625}
2626
2627#[allow(clippy::result_large_err)] async fn stream_body_to_file(
2632 body: axum::body::Body,
2633 dest_path: &Path,
2634 max_bytes: u64,
2635) -> Result<u64, Response> {
2636 use http_body_util::BodyExt as _;
2637 use tokio::io::AsyncWriteExt as _;
2638
2639 let mut file = match tokio::fs::File::create(dest_path).await {
2640 Ok(f) => f,
2641 Err(e) => {
2642 tracing::error!(
2643 event = "upload_io_error",
2644 "failed to create tarball temp file: {e}"
2645 );
2646 return Err((
2647 StatusCode::INTERNAL_SERVER_ERROR,
2648 Json(serde_json::json!({"error": "Upload initialization failed"})),
2649 )
2650 .into_response());
2651 }
2652 };
2653
2654 let mut body = body;
2655 let mut written: u64 = 0;
2656 loop {
2657 match body.frame().await {
2658 None => break,
2659 Some(Err(e)) => {
2660 let _ = tokio::fs::remove_file(dest_path).await;
2661 return Err((
2662 StatusCode::BAD_REQUEST,
2663 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2664 )
2665 .into_response());
2666 }
2667 Some(Ok(frame)) => {
2668 if let Ok(data) = frame.into_data() {
2669 written += data.len() as u64;
2670 if written > max_bytes {
2671 let _ = tokio::fs::remove_file(dest_path).await;
2672 return Err((
2673 StatusCode::PAYLOAD_TOO_LARGE,
2674 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2675 )
2676 .into_response());
2677 }
2678 if let Err(e) = file.write_all(&data).await {
2679 let _ = tokio::fs::remove_file(dest_path).await;
2680 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2681 return Err((
2682 StatusCode::INTERNAL_SERVER_ERROR,
2683 Json(serde_json::json!({"error": "Upload write failed"})),
2684 )
2685 .into_response());
2686 }
2687 }
2688 }
2689 }
2690 }
2691 drop(file);
2692 Ok(written)
2693}
2694
2695#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
2700 tarball_path: &Path,
2701 staging: &Path,
2702 max_decompressed_bytes: u64,
2703) -> Result<(), Response> {
2704 let staging_clone = staging.to_path_buf();
2705 let tarball_clone = tarball_path.to_path_buf();
2706 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2707 let file = std::fs::File::open(&tarball_clone)?;
2708 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2709 let limited = SizeLimitReader {
2710 inner: gz,
2711 remaining: max_decompressed_bytes,
2712 };
2713 let mut archive = tar::Archive::new(limited);
2714 archive.set_overwrite(true);
2715 archive.set_preserve_permissions(false);
2716 std::fs::create_dir_all(&staging_clone)?;
2717 archive.unpack(&staging_clone)?;
2718 Ok(())
2719 })
2720 .await;
2721 let _ = tokio::fs::remove_file(tarball_path).await;
2722
2723 match extract_result {
2724 Ok(Ok(())) => Ok(()),
2725 Ok(Err(e)) => {
2726 let _ = tokio::fs::remove_dir_all(staging).await;
2727 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2728 tracing::warn!(
2729 event = "upload_extract_error",
2730 "tarball extraction failed: {e:#}"
2731 );
2732 let (status, msg) = if is_size_limit {
2733 (
2734 StatusCode::PAYLOAD_TOO_LARGE,
2735 "Archive exceeds the decompressed size limit",
2736 )
2737 } else {
2738 (StatusCode::BAD_REQUEST, "Failed to extract archive")
2739 };
2740 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2741 }
2742 Err(e) => {
2743 let _ = tokio::fs::remove_dir_all(staging).await;
2744 tracing::error!(
2745 event = "upload_extract_panic",
2746 "tarball extraction task panicked: {e}"
2747 );
2748 Err((
2749 StatusCode::INTERNAL_SERVER_ERROR,
2750 Json(serde_json::json!({"error": "Archive extraction failed"})),
2751 )
2752 .into_response())
2753 }
2754 }
2755}
2756
2757async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2761 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2762 let first = entries.next_entry().await.ok()??;
2763 if !first.path().is_dir() {
2764 return None;
2765 }
2766 if entries.next_entry().await.unwrap_or(None).is_some() {
2767 return None;
2768 }
2769 Some(first.path())
2770}
2771
2772#[derive(Deserialize)]
2779struct UploadDirRequest {
2780 files: Vec<UploadedFile>,
2781 upload_id: Option<String>,
2784}
2785
2786#[derive(Deserialize)]
2787struct UploadedFile {
2788 path: String,
2790 content: String,
2792}
2793
2794async fn upload_directory_handler(
2804 State(state): State<AppState>,
2805 Json(body): Json<UploadDirRequest>,
2806) -> Response {
2807 if !state.server_mode {
2808 return StatusCode::NOT_FOUND.into_response();
2809 }
2810 if let Err(resp) = validate_upload_dir_request(&body) {
2811 return resp;
2812 }
2813 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2816 match write_upload_files(&body.files, &staging, &upload_id).await {
2817 Ok((file_count, project_root)) => {
2818 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2819 Json(serde_json::json!({
2820 "tmp_path": scan_root.to_string_lossy(),
2821 "file_count": file_count,
2822 "upload_id": upload_id.clone()
2823 }))
2824 .into_response()
2825 }
2826 Err(resp) => resp,
2827 }
2828}
2829
2830#[derive(Deserialize)]
2832struct UploadFileRequest {
2833 filename: String,
2835 content: String,
2837}
2838
2839async fn upload_file_handler(
2845 State(state): State<AppState>,
2846 Json(body): Json<UploadFileRequest>,
2847) -> Response {
2848 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2851 return StatusCode::NOT_FOUND.into_response();
2852 }
2853
2854 let Ok(data) = base64::Engine::decode(
2855 &base64::engine::general_purpose::STANDARD,
2856 body.content.as_bytes(),
2857 ) else {
2858 return (
2859 StatusCode::BAD_REQUEST,
2860 Json(serde_json::json!({"error": "Invalid base64 content"})),
2861 )
2862 .into_response();
2863 };
2864
2865 if data.len() > MAX_FILE_BYTES {
2866 return (
2867 StatusCode::PAYLOAD_TOO_LARGE,
2868 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2869 )
2870 .into_response();
2871 }
2872
2873 let filename = std::path::Path::new(&body.filename)
2875 .file_name()
2876 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2877
2878 let upload_id = uuid::Uuid::new_v4();
2879 let staging = std::env::temp_dir()
2880 .join("oxide-sloc-uploads")
2881 .join(upload_id.to_string());
2882
2883 if tokio::fs::create_dir_all(&staging).await.is_err() {
2884 return (
2885 StatusCode::INTERNAL_SERVER_ERROR,
2886 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2887 )
2888 .into_response();
2889 }
2890
2891 let dest = staging.join(&filename);
2892 if tokio::fs::write(&dest, &data).await.is_err() {
2893 let _ = tokio::fs::remove_dir_all(&staging).await;
2894 return (
2895 StatusCode::INTERNAL_SERVER_ERROR,
2896 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2897 )
2898 .into_response();
2899 }
2900
2901 Json(serde_json::json!({
2902 "tmp_path": dest.to_string_lossy(),
2903 "upload_id": upload_id.to_string()
2904 }))
2905 .into_response()
2906}
2907
2908struct SizeLimitReader<R> {
2924 inner: R,
2925 remaining: u64,
2926}
2927impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2928 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2929 if self.remaining == 0 {
2930 return Err(std::io::Error::other("decompressed size limit exceeded"));
2931 }
2932 let n = self.inner.read(buf)?;
2933 self.remaining = self.remaining.saturating_sub(n as u64);
2934 Ok(n)
2935 }
2936}
2937
2938async fn upload_tarball_handler(
2939 State(state): State<AppState>,
2940 request: axum::extract::Request,
2941) -> Response {
2942 if !state.server_mode {
2943 return StatusCode::NOT_FOUND.into_response();
2944 }
2945
2946 let upload_id = uuid::Uuid::new_v4().to_string();
2947 let upload_base = upload_base_dir();
2948 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2949 let staging = upload_staging_path(&upload_id);
2950 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2951
2952 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2953 tracing::error!(
2954 event = "upload_io_error",
2955 "failed to create upload base dir: {e}"
2956 );
2957 return (
2958 StatusCode::INTERNAL_SERVER_ERROR,
2959 Json(serde_json::json!({"error": "Upload initialization failed"})),
2960 )
2961 .into_response();
2962 }
2963
2964 let compressed_bytes =
2966 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2967 Ok(n) => n,
2968 Err(resp) => return resp,
2969 };
2970
2971 if let Err(resp) =
2973 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2974 {
2975 return resp;
2976 }
2977
2978 let scan_root = find_single_top_dir(&staging)
2983 .await
2984 .unwrap_or_else(|| staging.clone());
2985
2986 let original_bytes = tokio::task::spawn_blocking({
2988 let p = scan_root.clone();
2989 move || dir_size_bytes(&p)
2990 })
2991 .await
2992 .unwrap_or(0);
2993
2994 Json(serde_json::json!({
2995 "tmp_path": scan_root.to_string_lossy(),
2996 "upload_id": upload_id,
2997 "compressed_bytes": compressed_bytes,
2998 "original_bytes": original_bytes,
2999 }))
3000 .into_response()
3001}
3002
3003#[derive(Deserialize)]
3004struct LocateReportForm {
3005 file_path: String,
3006 #[serde(default)]
3007 redirect_url: Option<String>,
3008 #[serde(default)]
3009 expected_run_id: Option<String>,
3010}
3011
3012fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3014 let html = ErrorTemplate {
3015 message: message.into(),
3016 last_report_url: Some("/view-reports".to_string()),
3017 last_report_label: Some("View Reports".to_string()),
3018 run_id: None,
3019 error_code: None,
3020 csp_nonce: csp_nonce.to_owned(),
3021 version: env!("CARGO_PKG_VERSION"),
3022 }
3023 .render()
3024 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3025 Html(html).into_response()
3026}
3027
3028fn registry_entry_from_run(
3030 run: &AnalysisRun,
3031 json_path: PathBuf,
3032 html_path: PathBuf,
3033) -> RegistryEntry {
3034 let project_label = run.input_roots.first().map_or_else(
3035 || "Unknown Project".to_string(),
3036 |r| sanitize_project_label(r),
3037 );
3038 RegistryEntry {
3039 run_id: run.tool.run_id.clone(),
3040 timestamp_utc: run.tool.timestamp_utc,
3041 project_label,
3042 input_roots: run.input_roots.clone(),
3043 json_path: Some(json_path),
3044 html_path: Some(html_path),
3045 pdf_path: None,
3046 summary: ScanSummarySnapshot::from(&run.summary_totals),
3047 csv_path: None,
3048 xlsx_path: None,
3049 git_branch: None,
3050 git_commit: None,
3051 git_commit_long: None,
3052 git_author: None,
3053 git_tags: None,
3054 git_nearest_tag: None,
3055 git_commit_date: None,
3056 }
3057}
3058
3059pub(crate) async fn register_artifacts_in_registry(
3062 state: &AppState,
3063 label: &str,
3064 run: &AnalysisRun,
3065 artifacts: &RunArtifacts,
3066) {
3067 let Some(json_path) = artifacts.json_path.clone() else {
3068 return;
3069 };
3070 let Some(html_path) = artifacts.html_path.clone() else {
3071 return;
3072 };
3073 let mut entry = registry_entry_from_run(run, json_path, html_path);
3074 entry.project_label = label.to_owned();
3075 let mut reg = state.registry.lock().await;
3076 reg.add_entry(entry);
3077 let _ = reg.save(&state.registry_path);
3078}
3079
3080fn is_html_report_file(p: &Path) -> bool {
3081 p.is_file()
3082 && p.extension()
3083 .and_then(|x| x.to_str())
3084 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3085 && p.file_name()
3086 .and_then(|n| n.to_str())
3087 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3088}
3089
3090fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3091 fs::read_dir(dir)
3092 .ok()?
3093 .flatten()
3094 .map(|e| e.path())
3095 .find(|p| is_html_report_file(p))
3096}
3097
3098fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3099 if let Some(f) = find_html_report_in_dir(dir) {
3100 return Some(f);
3101 }
3102 if let Ok(rd) = fs::read_dir(dir) {
3103 for entry in rd.flatten() {
3104 let sub = entry.path();
3105 if sub.is_dir() {
3106 if let Some(f) = find_html_report_in_dir(&sub) {
3107 return Some(f);
3108 }
3109 }
3110 }
3111 }
3112 None
3113}
3114
3115#[allow(clippy::result_large_err)]
3120fn validate_locate_request(
3121 state: &AppState,
3122 file_path: &str,
3123 csp_nonce: &str,
3124) -> Result<(PathBuf, PathBuf), Response> {
3125 let raw = PathBuf::from(file_path);
3126
3127 let html_path = if raw.is_dir() {
3129 let found = find_html_report_in_tree(&raw);
3130 match found {
3131 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3132 None => {
3133 return Err(locate_report_error(
3134 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3135 the folder that contains your scan output (result_*.html or report_*.html).",
3136 csp_nonce,
3137 ));
3138 }
3139 }
3140 } else {
3141 let file_ext = raw
3142 .extension()
3143 .and_then(|e| e.to_str())
3144 .unwrap_or("")
3145 .to_ascii_lowercase();
3146 if file_ext != "html" {
3147 return Err(locate_report_error(
3148 "Please select the scan output folder, or an .html report file directly.",
3149 csp_nonce,
3150 ));
3151 }
3152 match fs::canonicalize(&raw) {
3153 Ok(p) => strip_unc_prefix(p),
3154 Err(_) => {
3155 return Err(locate_report_error(
3156 "Report file not found or path is invalid.",
3157 csp_nonce,
3158 ));
3159 }
3160 }
3161 };
3162
3163 if state.server_mode {
3164 let output_root = resolve_output_root(None);
3165 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3166 if !html_path.starts_with(&canonical_root) {
3167 return Err(locate_report_error(
3168 "Report file must be within the configured output directory.",
3169 csp_nonce,
3170 ));
3171 }
3172 }
3173 let parent = match html_path.parent() {
3174 Some(p) => p.to_path_buf(),
3175 None => {
3176 return Err(locate_report_error(
3177 "Report file has no parent directory.",
3178 csp_nonce,
3179 ));
3180 }
3181 };
3182 Ok((html_path, parent))
3183}
3184
3185fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3187 if want_json {
3188 (
3189 StatusCode::UNPROCESSABLE_ENTITY,
3190 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3191 )
3192 .into_response()
3193 } else {
3194 locate_report_error(msg, csp_nonce)
3195 }
3196}
3197
3198fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3200 if want_json {
3201 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3202 } else {
3203 axum::response::Redirect::to(redirect).into_response()
3204 }
3205}
3206
3207fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3210 for jpath in candidates {
3211 if let Ok(run) = read_json(jpath) {
3212 if expected.is_empty() || run.tool.run_id == expected {
3213 return Some((jpath.clone(), run.tool.run_id));
3214 }
3215 }
3216 }
3217 None
3218}
3219
3220fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3221 html_path
3222 .parent()
3223 .and_then(|p| p.parent())
3224 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3225}
3226
3227fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3228 let mut hits = collect_result_json_candidates(scan_root);
3229 if hits.is_empty() {
3230 hits = collect_result_json_candidates(parent);
3231 }
3232 hits.sort();
3233 hits
3234}
3235
3236#[allow(clippy::too_many_lines)]
3237async fn locate_report_handler(
3238 State(state): State<AppState>,
3239 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3240 headers: axum::http::HeaderMap,
3241 Form(form): Form<LocateReportForm>,
3242) -> impl IntoResponse {
3243 let want_json = headers
3244 .get(axum::http::header::ACCEPT)
3245 .and_then(|v| v.to_str().ok())
3246 .is_some_and(|v| v.contains("application/json"));
3247
3248 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3249 Ok(v) => v,
3250 Err(resp) => {
3251 if want_json {
3252 return locate_handler_err(
3253 true,
3254 "No HTML report file found in the selected folder. \
3255 Make sure you selected the folder that contains your \
3256 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3257 .to_string(),
3258 &csp_nonce,
3259 );
3260 }
3261 return resp;
3262 }
3263 };
3264
3265 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3268 let scan_root: &Path = &scan_root_owned;
3269 let json_candidates = gather_json_candidates(scan_root, &parent);
3270
3271 let expected_run_id = form
3273 .expected_run_id
3274 .as_deref()
3275 .unwrap_or("")
3276 .trim()
3277 .to_string();
3278
3279 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3280
3281 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3283 let actual = json_candidates
3284 .iter()
3285 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3286 .unwrap_or_else(|| "unknown".to_string());
3287 return locate_handler_err(
3288 want_json,
3289 format!(
3290 "This folder contains a different scan.\n\n\
3291 Expected run ID : {expected_run_id}\n\
3292 Found run ID : {actual}\n\n\
3293 Please select the folder that contains the correct scan output."
3294 ),
3295 &csp_nonce,
3296 );
3297 }
3298
3299 let safe_redirect = form
3300 .redirect_url
3301 .as_deref()
3302 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3303 .unwrap_or("/view-reports?linked=1")
3304 .to_string();
3305
3306 let mut reg = state.registry.lock().await;
3307
3308 if let Some((json_path, run_id)) = matched_json {
3309 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3311 entry.html_path = Some(html_path);
3312 entry.json_path = Some(json_path);
3313 let _ = reg.save(&state.registry_path);
3314 drop(reg);
3315 state.artifacts.lock().await.remove(&run_id);
3317 return redirect_or_json_ok(want_json, &safe_redirect);
3318 }
3319 match read_json(&json_path) {
3321 Ok(run) => {
3322 let entry = registry_entry_from_run(&run, json_path, html_path);
3323 reg.add_entry(entry);
3324 let _ = reg.save(&state.registry_path);
3325 drop(reg);
3326 state.artifacts.lock().await.remove(&run_id);
3327 return redirect_or_json_ok(want_json, &safe_redirect);
3328 }
3329 Err(e) => {
3330 drop(reg);
3331 return locate_handler_err(
3332 want_json,
3333 format!(
3334 "Found the scan folder but could not parse the result JSON.\n\n\
3335 The file may have been saved by an older version of OxideSLOC. \
3336 Re-running the analysis will create a fresh, compatible record.\n\n\
3337 Error: {e}"
3338 ),
3339 &csp_nonce,
3340 );
3341 }
3342 }
3343 }
3344
3345 if let Some(entry) = reg
3347 .entries
3348 .iter_mut()
3349 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3350 {
3351 entry.html_path = Some(html_path.clone());
3352 let _ = reg.save(&state.registry_path);
3353 drop(reg);
3354 state.artifacts.lock().await.remove(&expected_run_id);
3355 return redirect_or_json_ok(want_json, &safe_redirect);
3356 }
3357
3358 drop(reg);
3359 let hint = if state.server_mode {
3360 String::new()
3361 } else {
3362 format!(
3363 "\n\nSearched folder : {}\nHTML found : {}",
3364 scan_root.display(),
3365 html_path.display()
3366 )
3367 };
3368 locate_handler_err(
3369 want_json,
3370 format!(
3371 "Could not link this report.\n\n\
3372 No result_*.json was found in the selected folder. \
3373 Make sure you selected the top-level scan output folder \
3374 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3375 ),
3376 &csp_nonce,
3377 )
3378}
3379
3380fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3382 fs::read_dir(dir)
3383 .ok()?
3384 .flatten()
3385 .map(|e| e.path())
3386 .find(|p| {
3387 p.is_file()
3388 && p.file_stem()
3389 .and_then(|n| n.to_str())
3390 .is_some_and(|n| n.starts_with("result"))
3391 && p.extension()
3392 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3393 })
3394}
3395
3396#[derive(Deserialize)]
3397struct LocateReportsDirForm {
3398 folder_path: String,
3399}
3400
3401#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3403 State(state): State<AppState>,
3404 Form(form): Form<LocateReportsDirForm>,
3405) -> impl IntoResponse {
3406 if state.server_mode {
3407 return StatusCode::NOT_FOUND.into_response();
3408 }
3409 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3410 Ok(p) => strip_unc_prefix(p),
3411 Err(_) => {
3412 return axum::response::Redirect::to(
3413 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3414 )
3415 .into_response();
3416 }
3417 };
3418 if !folder.is_dir() {
3419 return axum::response::Redirect::to(
3420 "/view-reports?error=Selected+path+is+not+a+directory.",
3421 )
3422 .into_response();
3423 }
3424
3425 let candidates = collect_result_json_candidates(&folder);
3426
3427 if candidates.is_empty() {
3428 return axum::response::Redirect::to(
3429 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3430 )
3431 .into_response();
3432 }
3433
3434 let mut linked_count: usize = 0;
3435 let mut reg = state.registry.lock().await;
3436 for json_path in candidates {
3437 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3438 continue;
3439 };
3440 if is_dir_already_registered(®, &parent) {
3441 continue;
3442 }
3443 let Some(entry) = build_registry_entry_from_json(json_path) else {
3444 continue;
3445 };
3446 reg.add_entry(entry);
3447 linked_count += 1;
3448 }
3449 let _ = reg.save(&state.registry_path);
3450 drop(reg);
3451
3452 if linked_count == 0 {
3453 return axum::response::Redirect::to(
3454 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3455 )
3456 .into_response();
3457 }
3458 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3459}
3460
3461#[derive(Deserialize)]
3462struct RelocateScanForm {
3463 run_id: String,
3464 folder_path: String,
3465 redirect_url: String,
3466}
3467
3468fn relocate_folder_err(
3471 want_json: bool,
3472 status: StatusCode,
3473 msg: &str,
3474 run_id: &str,
3475 folder_hint: &str,
3476 redirect_url: &str,
3477 csp_nonce: &str,
3478) -> Response {
3479 if want_json {
3480 (
3481 status,
3482 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3483 )
3484 .into_response()
3485 } else {
3486 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3487 }
3488}
3489
3490#[allow(clippy::too_many_lines)]
3491async fn relocate_scan_handler(
3492 State(state): State<AppState>,
3493 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3494 headers: axum::http::HeaderMap,
3495 Form(form): Form<RelocateScanForm>,
3496) -> impl IntoResponse {
3497 let want_json = headers
3498 .get(axum::http::header::ACCEPT)
3499 .and_then(|v| v.to_str().ok())
3500 .is_some_and(|v| v.contains("application/json"));
3501 if state.server_mode {
3502 return StatusCode::NOT_FOUND.into_response();
3503 }
3504
3505 let run_id = form.run_id.trim().to_string();
3506 let redirect_url = form.redirect_url.trim().to_string();
3507
3508 let run_exists = {
3509 let reg = state.registry.lock().await;
3510 reg.find_by_run_id(&run_id).is_some()
3511 };
3512 if !run_exists {
3513 if want_json {
3514 return (
3515 StatusCode::NOT_FOUND,
3516 axum::Json(serde_json::json!({
3517 "ok": false,
3518 "message": format!("Run ID '{run_id}' not found in registry.")
3519 })),
3520 )
3521 .into_response();
3522 }
3523 let html = ErrorTemplate {
3524 message: format!("Run ID '{run_id}' not found in registry."),
3525 last_report_url: Some("/compare-scans".to_string()),
3526 last_report_label: Some("Compare Scans".to_string()),
3527 run_id: Some(run_id.clone()),
3528 error_code: Some(404),
3529 csp_nonce: csp_nonce.clone(),
3530 version: env!("CARGO_PKG_VERSION"),
3531 }
3532 .render()
3533 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3534 return Html(html).into_response();
3535 }
3536
3537 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3538 Ok(p) => strip_unc_prefix(p),
3539 Err(_) => {
3540 return relocate_folder_err(
3541 want_json,
3542 StatusCode::UNPROCESSABLE_ENTITY,
3543 "Folder not found or path is invalid.",
3544 &run_id,
3545 form.folder_path.trim(),
3546 &redirect_url,
3547 &csp_nonce,
3548 );
3549 }
3550 };
3551 if !folder.is_dir() {
3552 return relocate_folder_err(
3553 want_json,
3554 StatusCode::UNPROCESSABLE_ENTITY,
3555 "Selected path is not a directory.",
3556 &run_id,
3557 &folder.display().to_string(),
3558 &redirect_url,
3559 &csp_nonce,
3560 );
3561 }
3562
3563 let json_candidates = find_result_files_by_ext(&folder, "json");
3564 if json_candidates.is_empty() {
3565 let msg = format!(
3566 "No result JSON files found in the selected folder.\nSearched: {}",
3567 folder.display()
3568 );
3569 return relocate_folder_err(
3570 want_json,
3571 StatusCode::UNPROCESSABLE_ENTITY,
3572 &msg,
3573 &run_id,
3574 &folder.display().to_string(),
3575 &redirect_url,
3576 &csp_nonce,
3577 );
3578 }
3579
3580 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3581 let msg = format!(
3582 "No matching scan found in the selected folder.\n\
3583 The JSON files present do not contain run ID: {run_id}\n\
3584 Searched: {}",
3585 folder.display()
3586 );
3587 return relocate_folder_err(
3588 want_json,
3589 StatusCode::UNPROCESSABLE_ENTITY,
3590 &msg,
3591 &run_id,
3592 &folder.display().to_string(),
3593 &redirect_url,
3594 &csp_nonce,
3595 );
3596 };
3597
3598 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3599 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3600 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3601
3602 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3603 redirect_url
3604 } else {
3605 "/compare-scans".to_string()
3606 };
3607 redirect_or_json_ok(want_json, &safe_redirect)
3608}
3609
3610fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3611 let mut out = Vec::new();
3612 collect_scan_files_by_ext(folder, ext, &mut out);
3613 if let Ok(rd) = fs::read_dir(folder) {
3614 for entry in rd.flatten() {
3615 let sub = entry.path();
3616 if sub.is_dir() {
3617 collect_scan_files_by_ext(&sub, ext, &mut out);
3618 }
3619 }
3620 }
3621 out
3622}
3623
3624fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3625 let Ok(rd) = fs::read_dir(dir) else { return };
3626 for entry in rd.flatten() {
3627 let p = entry.path();
3628 if p.is_file()
3629 && p.file_stem()
3630 .and_then(|n| n.to_str())
3631 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3632 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3633 {
3634 out.push(p);
3635 }
3636 }
3637}
3638
3639fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3640 candidates
3641 .iter()
3642 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3643 .cloned()
3644}
3645
3646fn output_folder_hint(json_path: &std::path::Path) -> String {
3651 let Some(direct_parent) = json_path.parent() else {
3652 return String::new();
3653 };
3654 let parent_name = direct_parent
3655 .file_name()
3656 .and_then(|n| n.to_str())
3657 .unwrap_or("");
3658 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3659 direct_parent.parent().map_or_else(
3660 || direct_parent.display().to_string(),
3661 |p| p.display().to_string(),
3662 )
3663 } else {
3664 direct_parent.display().to_string()
3665 }
3666}
3667
3668async fn update_run_file_paths(
3669 state: &AppState,
3670 run_id: &str,
3671 json_path: PathBuf,
3672 html_path: Option<PathBuf>,
3673 pdf_path: Option<PathBuf>,
3674) {
3675 {
3676 let mut reg = state.registry.lock().await;
3677 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3678 entry.json_path = Some(json_path.clone());
3679 if let Some(ref hp) = html_path {
3680 entry.html_path = Some(hp.clone());
3681 }
3682 if let Some(ref pp) = pdf_path {
3683 entry.pdf_path = Some(pp.clone());
3684 }
3685 }
3686 let _ = reg.save(&state.registry_path);
3687 }
3688 {
3691 let mut map = state.artifacts.lock().await;
3692 if let Some(arts) = map.get_mut(run_id) {
3693 arts.json_path = Some(json_path);
3694 if let Some(hp) = html_path {
3695 arts.html_path = Some(hp);
3696 }
3697 if let Some(pp) = pdf_path {
3698 arts.pdf_path = Some(pp);
3699 }
3700 }
3701 }
3702}
3703
3704fn missing_scan_relocate_response(
3705 message: &str,
3706 run_id: &str,
3707 folder_hint: &str,
3708 redirect_url: &str,
3709 server_mode: bool,
3710 csp_nonce: &str,
3711) -> axum::response::Response {
3712 let html = RelocateScanTemplate {
3713 message: message.to_string(),
3714 run_id: run_id.to_string(),
3715 folder_hint: folder_hint.to_string(),
3716 redirect_url: redirect_url.to_string(),
3717 server_mode,
3718 csp_nonce: csp_nonce.to_owned(),
3719 version: env!("CARGO_PKG_VERSION"),
3720 }
3721 .render()
3722 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3723 (StatusCode::NOT_FOUND, Html(html)).into_response()
3724}
3725
3726fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3730 fs::read_dir(dir)
3731 .ok()?
3732 .flatten()
3733 .map(|e| e.path())
3734 .find(|p| {
3735 p.is_file()
3736 && p.extension()
3737 .and_then(|e| e.to_str())
3738 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3739 })
3740}
3741
3742fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3746 let mut out = Vec::new();
3747 if let Some(j) = find_result_json_in_dir(sub) {
3748 out.push(j);
3749 }
3750 let json_sub = sub.join("json");
3751 if json_sub.is_dir() {
3752 if let Some(j) = find_result_json_in_dir(&json_sub) {
3753 out.push(j);
3754 }
3755 }
3756 out
3757}
3758
3759fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3760 let mut candidates = Vec::new();
3761 if let Some(j) = find_result_json_in_dir(folder) {
3762 candidates.push(j);
3763 }
3764 let Ok(dir_entries) = fs::read_dir(folder) else {
3765 return candidates;
3766 };
3767 for entry in dir_entries.flatten() {
3768 let sub = entry.path();
3769 if sub.is_dir() {
3770 candidates.extend(subdir_result_json_candidates(&sub));
3771 }
3772 }
3773 candidates
3774}
3775
3776fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3777 reg.entries.iter().any(|e| {
3778 let dir_match = e
3779 .json_path
3780 .as_ref()
3781 .and_then(|p| p.parent())
3782 .is_some_and(|p| p == parent)
3783 || e.html_path
3784 .as_ref()
3785 .and_then(|p| p.parent())
3786 .is_some_and(|p| p == parent);
3787 dir_match
3788 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3789 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3790 })
3791}
3792
3793fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3794 let json_dir = json_path.parent()?.to_path_buf();
3795 let (html_path, pdf_path, csv_path, xlsx_path) =
3798 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3799 let scan_root = json_dir.parent()?;
3800 let html = find_html_report_in_dir(&scan_root.join("html"))
3801 .or_else(|| find_html_report_in_dir(scan_root));
3802 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3803 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3804 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3805 (html, pdf, csv, xlsx)
3806 } else {
3807 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3808 rd.flatten()
3809 .map(|e| e.path())
3810 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3811 });
3812 (html, None, None, None)
3813 };
3814 let run = read_json(&json_path).ok()?;
3815 let project_label = run.input_roots.first().map_or_else(
3816 || "Unknown Project".to_string(),
3817 |r| sanitize_project_label(r),
3818 );
3819 Some(RegistryEntry {
3820 run_id: run.tool.run_id.clone(),
3821 timestamp_utc: run.tool.timestamp_utc,
3822 project_label,
3823 input_roots: run.input_roots.clone(),
3824 json_path: Some(json_path),
3825 html_path,
3826 pdf_path,
3827 csv_path,
3828 xlsx_path,
3829 summary: ScanSummarySnapshot::from(&run.summary_totals),
3830 git_branch: run.git_branch.clone(),
3831 git_commit: run.git_commit_short.clone(),
3832 git_commit_long: run.git_commit_long.clone(),
3833 git_author: run.git_commit_author.clone(),
3834 git_tags: run.git_tags.clone(),
3835 git_nearest_tag: run.git_nearest_tag.clone(),
3836 git_commit_date: run.git_commit_date,
3837 })
3838}
3839
3840fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3843 let mut linked = 0usize;
3844 for json_path in collect_result_json_candidates(folder) {
3845 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3846 continue;
3847 };
3848 if is_dir_already_registered(reg, &parent) {
3849 continue;
3850 }
3851 let Some(entry) = build_registry_entry_from_json(json_path) else {
3852 continue;
3853 };
3854 reg.add_entry(entry);
3855 linked += 1;
3856 }
3857 linked
3858}
3859
3860async fn auto_scan_watched_dirs(state: &AppState) {
3862 let dirs: Vec<PathBuf> = {
3863 let wd = state.watched_dirs.lock().await;
3864 wd.dirs.clone()
3865 };
3866 if dirs.is_empty() {
3867 return;
3868 }
3869 let mut reg = state.registry.lock().await;
3870 let mut total = 0usize;
3871 for dir in &dirs {
3872 if dir.is_dir() {
3873 total += scan_folder_into_registry(dir, &mut reg);
3874 }
3875 }
3876 if total > 0 {
3877 let _ = reg.save(&state.registry_path);
3878 }
3879}
3880
3881#[derive(Deserialize)]
3884struct WatchedDirForm {
3885 folder_path: String,
3886 #[serde(default = "default_redirect")]
3887 redirect_to: String,
3888}
3889
3890fn default_redirect() -> String {
3891 "/view-reports".to_string()
3892}
3893
3894#[derive(Deserialize)]
3895struct WatchedDirRefreshForm {
3896 #[serde(default = "default_redirect")]
3897 redirect_to: String,
3898}
3899
3900fn safe_redirect(dest: &str) -> &str {
3904 if dest.starts_with('/') {
3905 dest
3906 } else {
3907 "/"
3908 }
3909}
3910
3911async fn add_watched_dir_handler(
3914 State(state): State<AppState>,
3915 Form(form): Form<WatchedDirForm>,
3916) -> impl IntoResponse {
3917 if state.server_mode {
3918 return StatusCode::NOT_FOUND.into_response();
3919 }
3920 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3921 strip_unc_prefix(p)
3922 } else {
3923 let dest = format!(
3924 "{}?error=Folder+not+found+or+path+is+invalid.",
3925 safe_redirect(&form.redirect_to)
3926 );
3927 return axum::response::Redirect::to(&dest).into_response();
3928 };
3929 if !folder.is_dir() {
3930 let dest = format!(
3931 "{}?error=Selected+path+is+not+a+directory.",
3932 safe_redirect(&form.redirect_to)
3933 );
3934 return axum::response::Redirect::to(&dest).into_response();
3935 }
3936
3937 {
3939 let mut wd = state.watched_dirs.lock().await;
3940 wd.add(folder.clone());
3941 let _ = wd.save(&state.watched_dirs_path);
3942 }
3943
3944 let linked = {
3946 let mut reg = state.registry.lock().await;
3947 let n = scan_folder_into_registry(&folder, &mut reg);
3948 if n > 0 {
3949 let _ = reg.save(&state.registry_path);
3950 }
3951 n
3952 };
3953
3954 let dest = if linked > 0 {
3955 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3956 } else {
3957 format!(
3958 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3959 safe_redirect(&form.redirect_to)
3960 )
3961 };
3962 axum::response::Redirect::to(&dest).into_response()
3963}
3964
3965async fn remove_watched_dir_handler(
3966 State(state): State<AppState>,
3967 Form(form): Form<WatchedDirForm>,
3968) -> impl IntoResponse {
3969 if state.server_mode {
3970 return StatusCode::NOT_FOUND.into_response();
3971 }
3972 let folder = PathBuf::from(&form.folder_path);
3973 {
3974 let mut wd = state.watched_dirs.lock().await;
3975 wd.remove(&folder);
3976 let _ = wd.save(&state.watched_dirs_path);
3977 }
3978 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3979}
3980
3981async fn refresh_watched_dirs_handler(
3982 State(state): State<AppState>,
3983 Form(form): Form<WatchedDirRefreshForm>,
3984) -> impl IntoResponse {
3985 if state.server_mode {
3986 return StatusCode::NOT_FOUND.into_response();
3987 }
3988 let dirs: Vec<PathBuf> = {
3989 let wd = state.watched_dirs.lock().await;
3990 wd.dirs.clone()
3991 };
3992 let mut total = 0usize;
3993 {
3994 let mut reg = state.registry.lock().await;
3995 reg.prune_stale();
3996 for dir in &dirs {
3997 if dir.is_dir() {
3998 total += scan_folder_into_registry(dir, &mut reg);
3999 }
4000 }
4001 let _ = reg.save(&state.registry_path);
4002 }
4003 let dest = if total > 0 {
4004 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4005 } else {
4006 safe_redirect(&form.redirect_to).to_owned()
4007 };
4008 axum::response::Redirect::to(&dest).into_response()
4009}
4010
4011#[derive(Debug, Deserialize)]
4012struct OpenPathQuery {
4013 path: Option<String>,
4014}
4015
4016fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4017 let mut ancestor = std::path::Path::new(raw);
4018 loop {
4019 match ancestor.parent() {
4020 Some(p) => {
4021 ancestor = p;
4022 if ancestor.is_dir() {
4023 break;
4024 }
4025 }
4026 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4027 }
4028 }
4029 Ok(ancestor.to_path_buf())
4030}
4031
4032async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4033 match tokio::fs::canonicalize(raw).await {
4034 Ok(canonical) if canonical.is_file() => canonical
4035 .parent()
4036 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4037 Ok(p.to_path_buf())
4038 }),
4039 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4040 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4041 Err(_) => find_existing_ancestor(raw),
4042 }
4043}
4044
4045async fn open_path_handler(
4046 State(state): State<AppState>,
4047 Query(query): Query<OpenPathQuery>,
4048) -> impl IntoResponse {
4049 if state.server_mode {
4050 return Json(serde_json::json!({
4051 "server_mode_disabled": true,
4052 "message": "Opening a path in the file manager is only available in local desktop mode."
4053 }))
4054 .into_response();
4055 }
4056 if std::env::var("SLOC_HEADLESS").is_ok() {
4058 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4059 }
4060 let raw = match query.path.as_deref() {
4061 Some(p) if !p.is_empty() => p,
4062 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4063 };
4064
4065 let target = match resolve_open_target(raw).await {
4069 Ok(p) => p,
4070 Err((code, msg)) => return (code, msg).into_response(),
4071 };
4072
4073 #[cfg(target_os = "windows")]
4074 win_dialog_focus::open_folder_foreground(target);
4075 #[cfg(target_os = "macos")]
4076 let _ = std::process::Command::new("open")
4077 .arg(&target)
4078 .stdout(Stdio::null())
4079 .stderr(Stdio::null())
4080 .spawn();
4081 #[cfg(target_os = "linux")]
4082 {
4083 let folder_name = target
4084 .file_name()
4085 .and_then(|n| n.to_str())
4086 .map(str::to_owned);
4087 let _ = std::process::Command::new("xdg-open")
4088 .arg(&target)
4089 .stdout(Stdio::null())
4090 .stderr(Stdio::null())
4091 .spawn();
4092 if let Some(name) = folder_name {
4096 std::thread::spawn(move || {
4097 std::thread::sleep(std::time::Duration::from_millis(800));
4098 let _ = std::process::Command::new("wmctrl")
4099 .args(["-a", &name])
4100 .stdout(Stdio::null())
4101 .stderr(Stdio::null())
4102 .spawn();
4103 });
4104 }
4105 }
4106
4107 Json(serde_json::json!({"ok": true})).into_response()
4108}
4109
4110async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4111 let (content_type, bytes): (&'static str, &'static [u8]) =
4112 match (folder.as_str(), file.as_str()) {
4113 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4114 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4115 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4116 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4117 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4118 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4119 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4120 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4121 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4122 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4123 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4124 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4125 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4126 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4127 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4128 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4129 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4130 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4131 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4132 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4133 _ => return StatusCode::NOT_FOUND.into_response(),
4134 };
4135 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4136}
4137
4138fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4143 let Ok(canonical) = fs::canonicalize(resolved) else {
4148 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4149 return Err(Html(
4150 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4151 ));
4152 }
4153 return Ok(());
4154 };
4155 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4157 return Ok(());
4158 }
4159 let config = &state.base_config;
4160 if config.discovery.allowed_scan_roots.is_empty() {
4161 return Err(Html(
4162 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4163 ));
4164 }
4165 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4166 fs::canonicalize(root)
4167 .ok()
4168 .is_some_and(|r| canonical.starts_with(&r))
4169 });
4170 if !allowed {
4171 return Err(Html(
4172 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4173 ));
4174 }
4175 Ok(())
4176}
4177
4178async fn preview_handler(
4179 State(state): State<AppState>,
4180 Query(query): Query<PreviewQuery>,
4181) -> impl IntoResponse {
4182 let raw_path = query
4183 .path
4184 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
4185 let resolved = resolve_input_path(&raw_path);
4186
4187 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4191 return Html(
4192 r#"<div class="preview-error">Sample directory not available on this server.
4193 Enter a path to a project directory or upload files using Browse.</div>"#
4194 .to_string(),
4195 );
4196 }
4197
4198 if state.server_mode {
4199 if let Err(resp) = authorize_preview_path(&state, &resolved) {
4200 return resp;
4201 }
4202 }
4203
4204 let include_patterns = split_patterns(query.include_globs.as_deref());
4205 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4206
4207 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4208 Ok(html) => Html(html),
4209 Err(err) => Html(format!(
4210 r#"<div class="preview-error">Preview failed: {}</div>"#,
4211 escape_html(&err.to_string())
4212 )),
4213 }
4214}
4215
4216#[derive(Debug, Deserialize, Default)]
4217struct SuggestCoverageQuery {
4218 path: Option<String>,
4219}
4220
4221#[derive(Serialize)]
4222struct SuggestCoverageResponse {
4223 found: Option<String>,
4224 tool: Option<&'static str>,
4225 hint: Option<&'static str>,
4226}
4227
4228async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4229 const CANDIDATES: &[&str] = &[
4230 "coverage/lcov.info",
4232 "lcov.info",
4233 "target/llvm-cov/lcov.info",
4234 "target/coverage/lcov.info",
4235 "target/debug/coverage/lcov.info",
4236 "coverage/coverage.lcov",
4237 "build/coverage/lcov.info",
4238 "reports/lcov.info",
4239 "coverage.xml",
4241 "coverage/coverage.xml",
4242 "target/site/cobertura/coverage.xml",
4243 "build/reports/coverage/coverage.xml",
4244 "target/site/jacoco/jacoco.xml",
4246 "build/reports/jacoco/test/jacocoTestReport.xml",
4247 "build/reports/jacoco/jacocoTestReport.xml",
4248 "build/jacoco/jacoco.xml",
4249 "coverage.json",
4251 "coverage/coverage.json",
4252 ];
4253 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4254 let found = CANDIDATES
4255 .iter()
4256 .map(|rel| root.join(rel))
4257 .find(|p| p.is_file())
4258 .map(|p| display_path(&p));
4259
4260 let (tool, hint) = detect_coverage_tool(&root);
4261 Json(SuggestCoverageResponse { found, tool, hint })
4262}
4263
4264fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4267 if root.join("Cargo.toml").is_file() {
4268 return (
4269 Some("cargo-llvm-cov"),
4270 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4271 );
4272 }
4273 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4274 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4275 }
4276 if root.join("pom.xml").is_file() {
4277 return (Some("jacoco"), Some("mvn test jacoco:report"));
4278 }
4279 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4280 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4281 }
4282 (None, None)
4283}
4284
4285#[allow(clippy::result_large_err)]
4287fn validate_server_scan_path(
4288 config: &sloc_config::AppConfig,
4289 resolved_path: &Path,
4290 csp_nonce: &str,
4291) -> Result<(), Response> {
4292 if config.discovery.allowed_scan_roots.is_empty() {
4293 let template = ErrorTemplate {
4294 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4295 Set allowed_scan_roots in the server config to permit scanning."
4296 .to_string(),
4297 last_report_url: None,
4298 last_report_label: None,
4299 run_id: None,
4300 error_code: Some(403),
4301 csp_nonce: csp_nonce.to_owned(),
4302 version: env!("CARGO_PKG_VERSION"),
4303 };
4304 return Err((
4305 StatusCode::FORBIDDEN,
4306 Html(
4307 template
4308 .render()
4309 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4310 ),
4311 )
4312 .into_response());
4313 }
4314 let Ok(canonical) = fs::canonicalize(resolved_path) else {
4319 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4320 "Scan path does not resolve to a real location");
4321 let template = ErrorTemplate {
4322 message: "The requested path could not be resolved to a real directory.".to_string(),
4323 last_report_url: None,
4324 last_report_label: None,
4325 run_id: None,
4326 error_code: Some(403),
4327 csp_nonce: csp_nonce.to_owned(),
4328 version: env!("CARGO_PKG_VERSION"),
4329 };
4330 return Err((
4331 StatusCode::FORBIDDEN,
4332 Html(
4333 template
4334 .render()
4335 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4336 ),
4337 )
4338 .into_response());
4339 };
4340 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4341 fs::canonicalize(root)
4342 .ok()
4343 .is_some_and(|r| canonical.starts_with(&r))
4344 });
4345 if !allowed {
4346 tracing::warn!(event = "path_rejected", path = %canonical.display(),
4347 "Scan path not in allowed_scan_roots");
4348 let template = ErrorTemplate {
4349 message: "The requested path is not within an allowed scan directory.".to_string(),
4350 last_report_url: None,
4351 last_report_label: None,
4352 run_id: None,
4353 error_code: Some(403),
4354 csp_nonce: csp_nonce.to_owned(),
4355 version: env!("CARGO_PKG_VERSION"),
4356 };
4357 return Err((
4358 StatusCode::FORBIDDEN,
4359 Html(
4360 template
4361 .render()
4362 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4363 ),
4364 )
4365 .into_response());
4366 }
4367 Ok(())
4368}
4369
4370fn apply_output_dir_exclusions(
4372 config: &mut sloc_config::AppConfig,
4373 project_path: &str,
4374 raw_output_dir: &str,
4375) {
4376 let project_root = resolve_input_path(project_path);
4377 let raw_out = raw_output_dir.trim();
4378 let resolved_out = if raw_out.is_empty() {
4379 project_root.join("sloc")
4380 } else if Path::new(raw_out).is_absolute() {
4381 PathBuf::from(raw_out)
4382 } else {
4383 workspace_root().join(raw_out)
4384 };
4385 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4386 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4387 let dir = first.to_string();
4388 if !config.discovery.excluded_directories.contains(&dir) {
4389 config.discovery.excluded_directories.push(dir);
4390 }
4391 }
4392 }
4393 if !config
4394 .discovery
4395 .excluded_directories
4396 .iter()
4397 .any(|d| d == "sloc")
4398 {
4399 config
4400 .discovery
4401 .excluded_directories
4402 .push("sloc".to_string());
4403 }
4404}
4405
4406const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4408 ScanSummarySnapshot {
4409 files_analyzed: run.summary_totals.files_analyzed,
4410 files_skipped: run.summary_totals.files_skipped,
4411 total_physical_lines: run.summary_totals.total_physical_lines,
4412 code_lines: run.summary_totals.code_lines,
4413 comment_lines: run.summary_totals.comment_lines,
4414 blank_lines: run.summary_totals.blank_lines,
4415 functions: run.summary_totals.functions,
4416 classes: run.summary_totals.classes,
4417 variables: run.summary_totals.variables,
4418 imports: run.summary_totals.imports,
4419 test_count: run.summary_totals.test_count,
4420 coverage_lines_found: run.summary_totals.coverage_lines_found,
4421 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4422 coverage_functions_found: run.summary_totals.coverage_functions_found,
4423 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4424 coverage_branches_found: run.summary_totals.coverage_branches_found,
4425 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4426 }
4427}
4428
4429pub(crate) fn build_run_registry_entry(
4431 run: &AnalysisRun,
4432 run_id: &str,
4433 project_label: &str,
4434 artifacts: &RunArtifacts,
4435) -> RegistryEntry {
4436 RegistryEntry {
4437 run_id: run_id.to_owned(),
4438 timestamp_utc: run.tool.timestamp_utc,
4439 project_label: project_label.to_owned(),
4440 input_roots: run.input_roots.clone(),
4441 json_path: artifacts.json_path.clone(),
4442 html_path: artifacts.html_path.clone(),
4443 pdf_path: artifacts.pdf_path.clone(),
4444 csv_path: artifacts.csv_path.clone(),
4445 xlsx_path: artifacts.xlsx_path.clone(),
4446 summary: summary_snapshot_from_run(run),
4447 git_branch: run.git_branch.clone(),
4448 git_commit: run.git_commit_short.clone(),
4449 git_commit_long: run.git_commit_long.clone(),
4450 git_author: run.git_commit_author.clone(),
4451 git_tags: run.git_tags.clone(),
4452 git_nearest_tag: run.git_nearest_tag.clone(),
4453 git_commit_date: run.git_commit_date.clone(),
4454 }
4455}
4456
4457fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4459 if let Some(policy) = form.mixed_line_policy {
4460 config.analysis.mixed_line_policy = policy;
4461 }
4462 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4463 config.analysis.generated_file_detection =
4464 form.generated_file_detection.as_deref() != Some("disabled");
4465 config.analysis.minified_file_detection =
4466 form.minified_file_detection.as_deref() != Some("disabled");
4467 config.analysis.vendor_directory_detection =
4468 form.vendor_directory_detection.as_deref() != Some("disabled");
4469 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4470 if let Some(binary_behavior) = form.binary_file_behavior {
4471 config.analysis.binary_file_behavior = binary_behavior;
4472 }
4473 apply_report_opts(config, form);
4474 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4475 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4476 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4477 if let Some(policy) = form.continuation_line_policy {
4478 config.analysis.continuation_line_policy = policy;
4479 }
4480 if let Some(policy) = form.blank_in_block_comment_policy {
4481 config.analysis.blank_in_block_comment_policy = policy;
4482 }
4483 config.analysis.count_compiler_directives =
4484 form.count_compiler_directives.as_deref() != Some("disabled");
4485 apply_style_threshold(config, form);
4486 apply_coverage_path(config, form);
4487}
4488
4489fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4490 if let Some(report_title) = form.report_title.as_deref() {
4491 let trimmed = report_title.trim();
4492 if !trimmed.is_empty() {
4493 config.reporting.report_title = trimmed.to_string();
4494 }
4495 }
4496 if let Some(hf) = form.report_header_footer.as_deref() {
4497 let trimmed = hf.trim();
4498 config.reporting.report_header_footer = if trimmed.is_empty() {
4499 None
4500 } else {
4501 Some(trimmed.to_string())
4502 };
4503 }
4504}
4505
4506fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4507 apply_style_col_threshold(config, form);
4508 apply_style_analysis_enabled(config, form);
4509 apply_style_score_threshold(config, form);
4510 apply_style_lang_scope(config, form);
4511 apply_activity_window(config, form);
4512}
4513
4514fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4515 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4516 if let Ok(t) = threshold_str.parse::<u16>() {
4517 if t == 80 || t == 100 || t == 120 {
4518 config.analysis.style_col_threshold = t;
4519 }
4520 }
4521 }
4522}
4523
4524fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4525 if let Some(v) = form.style_analysis_enabled.as_deref() {
4526 config.analysis.style_analysis_enabled = v != "disabled";
4527 }
4528}
4529
4530fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4531 if let Some(v) = form.style_score_threshold.as_deref() {
4532 if let Ok(t) = v.parse::<u8>() {
4533 config.analysis.style_score_threshold = t.min(100);
4534 }
4535 }
4536}
4537
4538fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4539 if let Some(v) = form.style_lang_scope.as_deref() {
4540 let scope = v.trim();
4541 if scope == "c_family" || scope == "all" {
4542 config.analysis.style_lang_scope = scope.to_string();
4543 }
4544 }
4545}
4546
4547fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4548 if let Some(w) = form.activity_window.as_deref() {
4551 let w = w.trim();
4552 if !w.is_empty() {
4553 if let Ok(days) = w.parse::<u32>() {
4554 config.analysis.activity_window_days = Some(days);
4555 }
4556 }
4557 }
4558}
4559
4560fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4561 if let Some(cov) = &form.coverage_file {
4562 let trimmed = cov.trim();
4563 if !trimmed.is_empty() {
4564 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4565 }
4566 }
4567}
4568
4569fn spawn_pdf_background(
4573 pending_pdf: PendingPdf,
4574 run_id: String,
4575 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4576) {
4577 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4578 tokio::spawn(async move {
4579 let result = tokio::task::spawn_blocking(move || {
4580 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4581 if cleanup_src {
4582 let _ = fs::remove_file(&pdf_src);
4583 }
4584 r
4585 })
4586 .await;
4587 let failed = match result {
4588 Ok(Ok(())) => false,
4589 Ok(Err(err)) => {
4590 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4591 true
4592 }
4593 Err(err) => {
4594 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4595 true
4596 }
4597 };
4598 if failed {
4599 let mut map = artifacts.lock().await;
4600 if let Some(entry) = map.get_mut(&run_id) {
4601 entry.pdf_path = None;
4602 }
4603 }
4604 });
4605 }
4606}
4607
4608fn spawn_native_pdf_background(
4612 json_path: PathBuf,
4613 pdf_dest: PathBuf,
4614 run_id: String,
4615 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4616) {
4617 tokio::spawn(async move {
4618 let result = tokio::task::spawn_blocking(move || {
4619 let run = sloc_core::read_json(&json_path)?;
4620 write_pdf_from_run(&run, &pdf_dest)
4621 })
4622 .await;
4623 let failed = match result {
4624 Ok(Ok(())) => false,
4625 Ok(Err(err)) => {
4626 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4627 true
4628 }
4629 Err(err) => {
4630 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4631 true
4632 }
4633 };
4634 if failed {
4635 let mut map = artifacts.lock().await;
4636 if let Some(entry) = map.get_mut(&run_id) {
4637 entry.pdf_path = None;
4638 }
4639 }
4640 });
4641}
4642
4643fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4645 cmp.file_deltas
4646 .iter()
4647 .map(|f| match f.status {
4648 FileChangeStatus::Added => f.current_code,
4649 FileChangeStatus::Modified => f.code_delta.max(0),
4650 _ => 0,
4651 })
4652 .sum()
4653}
4654
4655fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4657 cmp.file_deltas
4658 .iter()
4659 .map(|f| match f.status {
4660 FileChangeStatus::Removed => f.baseline_code,
4661 FileChangeStatus::Modified => (-f.code_delta).max(0),
4662 _ => 0,
4663 })
4664 .sum()
4665}
4666
4667fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4669 cmp.file_deltas
4670 .iter()
4671 .filter(|f| f.status == FileChangeStatus::Unchanged)
4672 .map(|f| f.current_code)
4673 .sum()
4674}
4675
4676fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4678 cmp.file_deltas
4679 .iter()
4680 .filter(|f| f.status == FileChangeStatus::Modified)
4681 .map(|f| f.current_code)
4682 .sum()
4683}
4684
4685fn build_submodule_row(
4687 s: &sloc_core::SubmoduleSummary,
4688 run: &AnalysisRun,
4689 run_id: &str,
4690 run_dir: &Path,
4691) -> SubmoduleRow {
4692 let safe = sanitize_project_label(&s.name);
4693 let artifact_key = format!("sub_{safe}");
4694 let pdf_artifact_key = format!("sub_{safe}_pdf");
4695 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4696 let parent_path = run
4697 .input_roots
4698 .first()
4699 .map_or("", std::string::String::as_str);
4700 let sub_run = build_sub_run(run, s, parent_path);
4701 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4702 render_sub_report_html(&sub_run, Some(&pdf_server_url))
4703 .ok()
4704 .and_then(|sub_html| {
4705 let sub_dir = run_dir.join("submodules");
4706 let _ = fs::create_dir_all(&sub_dir);
4707 let html_path = sub_dir.join(format!("{artifact_key}.html"));
4708 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4709 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4712 let _ = write_pdf_from_run(&sub_run, &pdf_path);
4713 Some(format!("/runs/{artifact_key}/{run_id}"))
4714 } else {
4715 None
4716 }
4717 })
4718 } else {
4719 None
4720 };
4721 SubmoduleRow {
4722 name: s.name.clone(),
4723 relative_path: s.relative_path.clone(),
4724 files_analyzed: s.files_analyzed,
4725 code_lines: s.code_lines,
4726 comment_lines: s.comment_lines,
4727 blank_lines: s.blank_lines,
4728 total_physical_lines: s.total_physical_lines,
4729 html_url,
4730 }
4731}
4732
4733#[allow(clippy::similar_names)]
4736#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
4738async fn analyze_handler(
4739 State(state): State<AppState>,
4740 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4741 Form(form): Form<AnalyzeForm>,
4742) -> impl IntoResponse {
4743 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4744 let template = ErrorTemplate {
4745 message: format!(
4746 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4747 Please wait a moment and try again."
4748 ),
4749 last_report_url: None,
4750 last_report_label: None,
4751 run_id: None,
4752 error_code: Some(503),
4753 csp_nonce: csp_nonce.clone(),
4754 version: env!("CARGO_PKG_VERSION"),
4755 };
4756 return (
4757 StatusCode::SERVICE_UNAVAILABLE,
4758 Html(
4759 template
4760 .render()
4761 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4762 ),
4763 )
4764 .into_response();
4765 };
4766
4767 let mut config = state.base_config.clone();
4768
4769 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4770 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4771 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4772
4773 if !is_git_mode {
4774 let resolved_path = resolve_input_path(&form.path);
4775 if state.server_mode
4776 && !is_upload_tmp_path(&resolved_path)
4777 && !is_sample_path(&resolved_path)
4778 {
4779 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4780 return resp;
4781 }
4782 }
4783 config.discovery.root_paths = vec![resolved_path];
4784 }
4785
4786 apply_form_to_config(&mut config, &form);
4787 apply_output_dir_exclusions(
4788 &mut config,
4789 &form.path,
4790 form.output_dir.as_deref().unwrap_or(""),
4791 );
4792
4793 let wait_id = uuid::Uuid::new_v4().to_string();
4795 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4796
4797 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4799 let task_cancel = Arc::clone(&cancel_token);
4800
4801 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4803 let task_phase = Arc::clone(&phase);
4804
4805 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4806 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4807 let task_files_done = Arc::clone(&files_done);
4808 let task_files_total = Arc::clone(&files_total);
4809
4810 {
4813 let mut runs = state.async_runs.lock().await;
4814 runs.insert(
4815 wait_id.clone(),
4816 AsyncRunState::Running {
4817 started_at: std::time::Instant::now(),
4818 cancel_token,
4819 phase,
4820 files_done,
4821 files_total,
4822 },
4823 );
4824 }
4825
4826 let task = AnalysisTask {
4827 sem_permit,
4828 state: state.clone(),
4829 wait_id: wait_id.clone(),
4830 config,
4831 cancel: task_cancel,
4832 phase: task_phase,
4833 files_done: task_files_done,
4834 files_total: task_files_total,
4835 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4836 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4837 project_path: form.path.clone(),
4838 output_dir: if state.server_mode {
4842 None
4843 } else {
4844 form.output_dir.clone()
4845 },
4846 clones_dir: state.git_clones_dir.clone(),
4847 cocomo_mode: form
4848 .cocomo_mode
4849 .clone()
4850 .unwrap_or_else(|| "organic".to_string()),
4851 complexity_alert: form
4852 .complexity_alert
4853 .as_deref()
4854 .and_then(|s| s.parse::<u32>().ok())
4855 .unwrap_or(0),
4856 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4857 };
4858
4859 tokio::spawn(run_analysis_task(task));
4860
4861 let template = ScanWaitTemplate {
4862 version: env!("CARGO_PKG_VERSION"),
4863 wait_id_json,
4864 project_path: form.path.clone(),
4865 csp_nonce,
4866 };
4867 let html = template
4868 .render()
4869 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4870 let mut response = Html(html).into_response();
4871 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4872 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4873 response.headers_mut().insert(name, val);
4874 }
4875 }
4876 response
4877}
4878
4879struct AnalysisTask {
4880 sem_permit: tokio::sync::OwnedSemaphorePermit,
4881 state: AppState,
4882 wait_id: String,
4883 config: AppConfig,
4884 cancel: Arc<std::sync::atomic::AtomicBool>,
4885 phase: Arc<std::sync::Mutex<String>>,
4886 files_done: Arc<std::sync::atomic::AtomicUsize>,
4887 files_total: Arc<std::sync::atomic::AtomicUsize>,
4888 git_repo: Option<String>,
4889 git_ref: Option<String>,
4890 project_path: String,
4891 output_dir: Option<String>,
4892 clones_dir: PathBuf,
4893 cocomo_mode: String,
4894 complexity_alert: u32,
4895 exclude_duplicates: bool,
4896}
4897
4898#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
4900 let _permit = task.sem_permit;
4901
4902 let cancel_sb = Arc::clone(&task.cancel);
4903 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4904 let clones_dir_sb = task.clones_dir;
4905 let upload_staging_root = task
4907 .config
4908 .discovery
4909 .root_paths
4910 .first()
4911 .filter(|p| is_upload_tmp_path(p))
4912 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4913 .map(PathBuf::from);
4914 let config_sb = task.config;
4915 let progress_sb = sloc_core::ProgressCounters {
4916 files_done: Arc::clone(&task.files_done),
4917 files_total: Arc::clone(&task.files_total),
4918 };
4919 if let Ok(mut p) = task.phase.lock() {
4920 *p = "Scanning files".to_string();
4921 }
4922 let analysis_result = tokio::task::spawn_blocking(move || {
4923 run_analysis_blocking(
4924 config_sb,
4925 git_repo_sb,
4926 git_ref_sb,
4927 clones_dir_sb,
4928 cancel_sb,
4929 Some(progress_sb),
4930 )
4931 })
4932 .await
4933 .map_err(|err| anyhow::anyhow!(err.to_string()))
4934 .and_then(|result| result);
4935
4936 if let Ok(mut p) = task.phase.lock() {
4937 *p = "Writing reports".to_string();
4938 }
4939
4940 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4942 let mut runs = task.state.async_runs.lock().await;
4943 if matches!(
4945 runs.get(&task.wait_id),
4946 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4947 ) {
4948 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4949 }
4950 drop(runs);
4951 return;
4952 }
4953
4954 let run = match analysis_result {
4955 Ok(v) => v,
4956 Err(err) => {
4957 if err.to_string().contains("analysis cancelled") {
4959 let mut runs = task.state.async_runs.lock().await;
4960 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4961 drop(runs);
4962 return;
4963 }
4964 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4965 let mut runs = task.state.async_runs.lock().await;
4966 runs.insert(
4967 task.wait_id.clone(),
4968 AsyncRunState::Failed {
4969 message: "Analysis failed. Check that the path exists and is readable."
4970 .to_string(),
4971 },
4972 );
4973 drop(runs);
4974 return;
4975 }
4976 };
4977
4978 let run_id = run.tool.run_id.clone();
4979 tracing::info!(event = "scan_complete", run_id = %run_id,
4980 path = %task.project_path, files = run.summary_totals.files_analyzed,
4981 "Analysis finished");
4982
4983 let prev_entry: Option<RegistryEntry> = {
4984 let reg = task.state.registry.lock().await;
4985 reg.entries_for_roots(&run.input_roots)
4986 .into_iter()
4987 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4988 .cloned()
4989 };
4990
4991 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4992 prev.json_path
4993 .as_ref()
4994 .and_then(|p| read_json(p).ok())
4995 .map(|prev_run| compute_delta(&prev_run, &run))
4996 });
4997 let prev_scan_count: usize = {
4998 let reg = task.state.registry.lock().await;
4999 reg.entries_for_roots(&run.input_roots)
5000 .iter()
5001 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5002 .count()
5003 };
5004
5005 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5008 .as_ref()
5009 .zip(prev_entry.as_ref())
5010 .map(|(cmp, prev)| ReportDeltaContext {
5011 delta_code_added: sum_added_code_lines(cmp),
5012 delta_code_removed: sum_removed_code_lines(cmp),
5013 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5014 delta_files_added: cmp.files_added,
5015 delta_files_removed: cmp.files_removed,
5016 delta_files_modified: cmp.files_modified,
5017 delta_files_unchanged: cmp.files_unchanged,
5018 prev_code_lines: prev.summary.code_lines,
5019 prev_scan_count: prev_scan_count + 1,
5020 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5021 prev_run_id: Some(prev.run_id.clone()),
5022 current_run_id: Some(run_id.clone()),
5023 });
5024 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5025 Ok(h) => h,
5026 Err(err) => {
5027 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5028 let mut runs = task.state.async_runs.lock().await;
5029 runs.insert(
5030 task.wait_id.clone(),
5031 AsyncRunState::Failed {
5032 message: "Failed to render HTML report.".to_string(),
5033 },
5034 );
5035 drop(runs);
5036 return;
5037 }
5038 };
5039
5040 let output_root = resolve_output_root(task.output_dir.as_deref());
5041 let project_label = derive_project_label(
5042 task.git_repo.as_deref(),
5043 task.git_ref.as_deref(),
5044 &task.project_path,
5045 );
5046 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5047 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5048
5049 let result_context = RunResultContext {
5050 prev_entry: prev_entry.clone(),
5051 prev_scan_count,
5052 project_path: task.project_path.clone(),
5053 cocomo_mode: task.cocomo_mode.clone(),
5054 complexity_alert: task.complexity_alert,
5055 exclude_duplicates: task.exclude_duplicates,
5056 };
5057
5058 let artifact_result = persist_run_artifacts(
5059 &run,
5060 &report_html,
5061 &run_dir,
5062 &run.effective_configuration.reporting.report_title,
5063 &file_stem,
5064 result_context,
5065 );
5066
5067 let (artifacts, pending_pdf) = match artifact_result {
5068 Ok(v) => v,
5069 Err(err) => {
5070 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5071 let mut runs = task.state.async_runs.lock().await;
5072 runs.insert(
5073 task.wait_id.clone(),
5074 AsyncRunState::Failed {
5075 message: "Failed to save report artifacts. Check available disk space."
5076 .to_string(),
5077 },
5078 );
5079 drop(runs);
5080 return;
5081 }
5082 };
5083
5084 {
5085 let mut map = task.state.artifacts.lock().await;
5086 map.insert(run_id.clone(), artifacts.clone());
5087 }
5088
5089 {
5090 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5091 let mut reg = task.state.registry.lock().await;
5092 reg.add_entry(entry);
5093 let _ = reg.save(&task.state.registry_path);
5094 }
5095
5096 if let Some(ref cfg_path) = artifacts.scan_config_path {
5097 save_scan_config_json(
5098 cfg_path,
5099 &run,
5100 &task.project_path,
5101 task.output_dir.as_deref(),
5102 &task.cocomo_mode,
5103 task.complexity_alert,
5104 task.exclude_duplicates,
5105 );
5106 }
5107
5108 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5109
5110 prom_runs_total().inc();
5111
5112 let mut runs = task.state.async_runs.lock().await;
5114 runs.insert(
5115 task.wait_id.clone(),
5116 AsyncRunState::Complete {
5117 run_id: run_id.clone(),
5118 },
5119 );
5120 drop(runs);
5121
5122 if let Some(staging) = upload_staging_root {
5125 let _ = tokio::fs::remove_dir_all(staging).await;
5126 }
5127
5128 let _ = scan_delta;
5129}
5130
5131fn save_scan_config_json(
5132 cfg_path: &std::path::Path,
5133 run: &sloc_core::AnalysisRun,
5134 project_path: &str,
5135 output_dir: Option<&str>,
5136 cocomo_mode: &str,
5137 complexity_alert: u32,
5138 exclude_duplicates: bool,
5139) {
5140 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5141 .ok()
5142 .and_then(|v| v.as_str().map(String::from))
5143 .unwrap_or_else(|| "code_only".to_string());
5144 let behavior_str =
5145 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5146 .ok()
5147 .and_then(|v| v.as_str().map(String::from))
5148 .unwrap_or_else(|| "skip".to_string());
5149 let continuation_policy_str = serde_json::to_value(
5150 run.effective_configuration
5151 .analysis
5152 .continuation_line_policy,
5153 )
5154 .ok()
5155 .and_then(|v| v.as_str().map(String::from))
5156 .unwrap_or_else(default_each_physical_line);
5157 let blank_policy_str = serde_json::to_value(
5158 run.effective_configuration
5159 .analysis
5160 .blank_in_block_comment_policy,
5161 )
5162 .ok()
5163 .and_then(|v| v.as_str().map(String::from))
5164 .unwrap_or_else(default_count_as_comment);
5165 let scan_cfg = ScanConfig {
5166 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5167 path: project_path.to_string(),
5168 include_globs: run
5169 .effective_configuration
5170 .discovery
5171 .include_globs
5172 .join("\n"),
5173 exclude_globs: run
5174 .effective_configuration
5175 .discovery
5176 .exclude_globs
5177 .join("\n"),
5178 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5179 mixed_line_policy: policy_str,
5180 python_docstrings_as_comments: run
5181 .effective_configuration
5182 .analysis
5183 .python_docstrings_as_comments,
5184 generated_file_detection: run
5185 .effective_configuration
5186 .analysis
5187 .generated_file_detection,
5188 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5189 vendor_directory_detection: run
5190 .effective_configuration
5191 .analysis
5192 .vendor_directory_detection,
5193 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5194 binary_file_behavior: behavior_str,
5195 output_dir: output_dir.unwrap_or("").to_string(),
5196 report_title: run.effective_configuration.reporting.report_title.clone(),
5197 continuation_line_policy: continuation_policy_str,
5198 blank_in_block_comment_policy: blank_policy_str,
5199 count_compiler_directives: run
5200 .effective_configuration
5201 .analysis
5202 .count_compiler_directives,
5203 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5204 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5205 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5206 style_lang_scope: run
5207 .effective_configuration
5208 .analysis
5209 .style_lang_scope
5210 .clone(),
5211 coverage_file: run
5212 .effective_configuration
5213 .analysis
5214 .coverage_file
5215 .as_ref()
5216 .map(|p| p.display().to_string())
5217 .unwrap_or_default(),
5218 cocomo_mode: cocomo_mode.to_string(),
5219 complexity_alert,
5220 exclude_duplicates,
5221 activity_window: run
5222 .effective_configuration
5223 .analysis
5224 .activity_window_days
5225 .unwrap_or(0),
5226 };
5227 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5228 let _ = std::fs::write(cfg_path, json);
5229 }
5230}
5231
5232#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5234 mut config: AppConfig,
5235 git_repo: Option<String>,
5236 git_ref: Option<String>,
5237 clones_dir: PathBuf,
5238 cancel: Arc<std::sync::atomic::AtomicBool>,
5239 progress: Option<sloc_core::ProgressCounters>,
5240) -> Result<sloc_core::AnalysisRun> {
5241 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5242 let dest = git_clone_dest(&repo, &clones_dir);
5243 sloc_git::clone_or_fetch(&repo, &dest)?;
5244 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5245 sloc_git::create_worktree(&dest, &refname, &wt)?;
5246 config.discovery.root_paths = vec![wt.clone()];
5247 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5248 let _ = sloc_git::destroy_worktree(&dest, &wt);
5249 let mut run = run?;
5250 if run.git_branch.is_none() {
5251 run.git_branch = Some(refname);
5252 }
5253 return Ok(run);
5254 }
5255 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5256}
5257
5258fn derive_project_label(
5259 git_repo: Option<&str>,
5260 git_ref: Option<&str>,
5261 fallback_path: &str,
5262) -> String {
5263 match (
5264 git_repo.filter(|s| !s.is_empty()),
5265 git_ref.filter(|s| !s.is_empty()),
5266 ) {
5267 (Some(repo), Some(refname)) => {
5268 let repo_name = repo
5269 .trim_end_matches('/')
5270 .trim_end_matches(".git")
5271 .rsplit('/')
5272 .next()
5273 .unwrap_or("repo");
5274 sanitize_project_label(&format!("{repo_name}_{refname}"))
5275 }
5276 _ => sanitize_project_label(fallback_path),
5277 }
5278}
5279
5280fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5281 let commit = commit_short.unwrap_or("").trim();
5282 if commit.is_empty() {
5283 project_label.to_string()
5284 } else {
5285 format!("{project_label}_{commit}")
5286 }
5287}
5288
5289#[derive(Serialize)]
5292#[serde(tag = "state", rename_all = "snake_case")]
5293enum AsyncRunStatusResponse {
5294 Running {
5295 elapsed_secs: u64,
5296 phase: String,
5297 files_done: u64,
5298 files_total: u64,
5299 },
5300 Complete {
5301 run_id: String,
5302 },
5303 Failed {
5304 message: String,
5305 },
5306 Cancelled,
5307}
5308
5309async fn async_run_status_handler(
5310 State(state): State<AppState>,
5311 AxumPath(wait_id): AxumPath<String>,
5312) -> Response {
5313 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5315 return error::bad_request("invalid wait_id");
5316 }
5317 let run_state = {
5318 let runs = state.async_runs.lock().await;
5319 runs.get(&wait_id).cloned()
5320 };
5321 match run_state {
5322 None => error::not_found("run not found"),
5323 Some(AsyncRunState::Running {
5324 started_at,
5325 phase,
5326 files_done,
5327 files_total,
5328 ..
5329 }) => {
5330 if started_at.elapsed() > std::time::Duration::from_hours(2) {
5332 let mut runs = state.async_runs.lock().await;
5333 runs.insert(
5334 wait_id,
5335 AsyncRunState::Failed {
5336 message: "Analysis timed out after 2 hours.".to_string(),
5337 },
5338 );
5339 drop(runs);
5340 return Json(AsyncRunStatusResponse::Failed {
5341 message: "Analysis timed out after 2 hours.".to_string(),
5342 })
5343 .into_response();
5344 }
5345 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5346 Json(AsyncRunStatusResponse::Running {
5347 elapsed_secs: started_at.elapsed().as_secs(),
5348 phase: phase_str,
5349 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5350 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5351 })
5352 .into_response()
5353 }
5354 Some(AsyncRunState::Complete { run_id }) => {
5355 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5356 }
5357 Some(AsyncRunState::Failed { message }) => {
5358 Json(AsyncRunStatusResponse::Failed { message }).into_response()
5359 }
5360 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5361 }
5362}
5363
5364async fn cancel_run_handler(
5365 State(state): State<AppState>,
5366 AxumPath(wait_id): AxumPath<String>,
5367) -> Response {
5368 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5369 return error::bad_request("invalid wait_id");
5370 }
5371 let mut runs = state.async_runs.lock().await;
5372 let resp = match runs.get(&wait_id) {
5373 Some(AsyncRunState::Running { cancel_token, .. }) => {
5374 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5375 runs.insert(wait_id, AsyncRunState::Cancelled);
5376 StatusCode::OK.into_response()
5377 }
5378 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5379 _ => error::not_found("run not found"),
5380 };
5381 drop(runs);
5382 resp
5383}
5384
5385async fn async_run_result_handler(
5386 State(state): State<AppState>,
5387 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5388 AxumPath(run_id): AxumPath<String>,
5389) -> Response {
5390 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5391 return StatusCode::BAD_REQUEST.into_response();
5392 }
5393
5394 let artifacts = {
5395 let map = state.artifacts.lock().await;
5396 map.get(&run_id).cloned()
5397 };
5398 let artifacts = if let Some(a) = artifacts {
5399 a
5400 } else {
5401 let reg = state.registry.lock().await;
5402 if let Some(entry) = reg.find_by_run_id(&run_id) {
5403 recover_artifacts_from_registry(entry)
5404 } else {
5405 let html = ErrorTemplate {
5406 message: format!(
5407 "Report not found. Run ID {} is not in the scan history.",
5408 &run_id[..run_id.len().min(8)]
5409 ),
5410 last_report_url: Some("/view-reports".to_string()),
5411 last_report_label: Some("View Reports".to_string()),
5412 run_id: Some(run_id.clone()),
5413 error_code: Some(404),
5414 csp_nonce: csp_nonce.clone(),
5415 version: env!("CARGO_PKG_VERSION"),
5416 }
5417 .render()
5418 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5419 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5420 }
5421 };
5422
5423 let json_path = if let Some(p) = &artifacts.json_path {
5424 p.clone()
5425 } else {
5426 let html = ErrorTemplate {
5427 message: "JSON result was not saved for this run.".to_string(),
5428 last_report_url: Some("/view-reports".to_string()),
5429 last_report_label: Some("View Reports".to_string()),
5430 run_id: Some(run_id.clone()),
5431 error_code: Some(404),
5432 csp_nonce: csp_nonce.clone(),
5433 version: env!("CARGO_PKG_VERSION"),
5434 }
5435 .render()
5436 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5437 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5438 };
5439
5440 let Ok(run) = read_json(&json_path) else {
5441 let folder_hint = output_folder_hint(&json_path);
5442 let redirect_url = format!("/runs/result/{run_id}");
5443 return missing_scan_relocate_response(
5444 &format!(
5445 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
5446 deleted. Browse to the folder containing your scan output to reconnect it.",
5447 json_path.display()
5448 ),
5449 &run_id,
5450 &folder_hint,
5451 &redirect_url,
5452 state.server_mode,
5453 &csp_nonce,
5454 );
5455 };
5456
5457 let confluence_configured = {
5458 let store = state.confluence.lock().await;
5459 store.is_configured()
5460 };
5461
5462 render_result_page(
5463 &run,
5464 &artifacts,
5465 &run_id,
5466 &csp_nonce,
5467 confluence_configured,
5468 state.server_mode,
5469 )
5470}
5471
5472fn json_escape(s: &str) -> String {
5474 s.replace('\\', "\\\\").replace('"', "\\\"")
5475}
5476
5477struct LangTotals {
5479 physical_lines: u64,
5480 code_lines: u64,
5481 comment_lines: u64,
5482 blank_lines: u64,
5483 mixed_lines: u64,
5484 functions: u64,
5485 classes: u64,
5486 variables: u64,
5487 imports: u64,
5488}
5489
5490fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5491 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5492 run.totals_by_language.iter().map(f).sum()
5493 };
5494 LangTotals {
5495 physical_lines: s(|r| r.total_physical_lines),
5496 code_lines: s(|r| r.code_lines),
5497 comment_lines: s(|r| r.comment_lines),
5498 blank_lines: s(|r| r.blank_lines),
5499 mixed_lines: s(|r| r.mixed_lines_separate),
5500 functions: s(|r| r.functions),
5501 classes: s(|r| r.classes),
5502 variables: s(|r| r.variables),
5503 imports: s(|r| r.imports),
5504 }
5505}
5506
5507struct DeltaFields {
5509 prev_fa_str: String,
5510 prev_fs_str: String,
5511 prev_pl_str: String,
5512 prev_cl_str: String,
5513 prev_cml_str: String,
5514 prev_bl_str: String,
5515 delta_fa_str: String,
5516 delta_fa_class: String,
5517 delta_fs_str: String,
5518 delta_fs_class: String,
5519 delta_pl_str: String,
5520 delta_pl_class: String,
5521 delta_cl_str: String,
5522 delta_cl_class: String,
5523 delta_cml_str: String,
5524 delta_cml_class: String,
5525 delta_bl_str: String,
5526 delta_bl_class: String,
5527 delta_lines_added: Option<i64>,
5528 delta_lines_removed: Option<i64>,
5529 delta_lines_net_str: String,
5530 delta_lines_net_class: String,
5531}
5532
5533#[allow(
5538 clippy::similar_names,
5539 reason = "locals mirror template-bound struct fields"
5540)]
5541fn compute_delta_fields(
5542 prev_entry: Option<&RegistryEntry>,
5543 totals: &LangTotals,
5544 files_analyzed: u64,
5545 files_skipped: u64,
5546 scan_delta: Option<&sloc_core::ScanComparison>,
5547) -> DeltaFields {
5548 let prev_sum = prev_entry.map(|e| &e.summary);
5549 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5550
5551 let (delta_fa_str, delta_fa_class) =
5552 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5553 let (delta_fs_str, delta_fs_class) =
5554 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5555 let (delta_pl_str, delta_pl_class) = summary_delta(
5556 totals.physical_lines,
5557 prev_sum.map(|s| s.total_physical_lines),
5558 );
5559 let (delta_cl_str, delta_cl_class) =
5560 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5561 let (delta_cml_str, delta_cml_class) =
5562 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5563 let (delta_bl_str, delta_bl_class) =
5564 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5565
5566 let delta_lines_added = scan_delta.map(sum_added_code_lines);
5567 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5568 let (delta_lines_net_str, delta_lines_net_class) =
5569 match (delta_lines_added, delta_lines_removed) {
5570 (Some(a), Some(r)) => {
5571 let net = a - r;
5572 (fmt_delta(net), delta_class(net).to_string())
5573 }
5574 _ => ("\u{2014}".to_string(), "na".to_string()),
5575 };
5576
5577 DeltaFields {
5578 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5579 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5580 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5581 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5582 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5583 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5584 delta_fa_str,
5585 delta_fa_class: delta_fa_class.to_string(),
5586 delta_fs_str,
5587 delta_fs_class: delta_fs_class.to_string(),
5588 delta_pl_str,
5589 delta_pl_class: delta_pl_class.to_string(),
5590 delta_cl_str,
5591 delta_cl_class: delta_cl_class.to_string(),
5592 delta_cml_str,
5593 delta_cml_class: delta_cml_class.to_string(),
5594 delta_bl_str,
5595 delta_bl_class: delta_bl_class.to_string(),
5596 delta_lines_added,
5597 delta_lines_removed,
5598 delta_lines_net_str,
5599 delta_lines_net_class,
5600 }
5601}
5602
5603fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5605 scan_delta
5606 .file_deltas
5607 .iter()
5608 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5609 .map(|f| {
5610 #[allow(clippy::cast_sign_loss)]
5611 let n = f.current_code as u64;
5612 n
5613 })
5614 .sum()
5615}
5616
5617fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5618 run.git_remote_url
5619 .as_deref()
5620 .zip(run.git_commit_long.as_deref())
5621 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5622}
5623
5624fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5625 run.git_remote_url
5626 .as_deref()
5627 .zip(run.git_branch.as_deref())
5628 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5629}
5630
5631fn scan_performed_by(run: &AnalysisRun) -> String {
5632 run.environment.ci_name.clone().unwrap_or_else(|| {
5633 format!(
5634 "{} / {}",
5635 run.environment.initiator_username, run.environment.initiator_hostname
5636 )
5637 })
5638}
5639
5640fn build_lang_chart_json(run: &AnalysisRun) -> String {
5642 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5643 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5644 let entries: Vec<String> = langs
5645 .into_iter()
5646 .take(12)
5647 .map(|l| {
5648 let name = json_escape(l.language.display_name());
5649 format!(
5650 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5651 name,
5652 l.code_lines,
5653 l.comment_lines,
5654 l.blank_lines,
5655 l.total_physical_lines,
5656 l.functions,
5657 l.classes,
5658 l.variables,
5659 l.imports,
5660 l.files,
5661 )
5662 })
5663 .collect();
5664 format!("[{}]", entries.join(","))
5665}
5666
5667fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5669 let entries: Vec<String> = run
5670 .totals_by_language
5671 .iter()
5672 .map(|l| {
5673 let name = json_escape(l.language.display_name());
5674 format!(
5675 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5676 name, l.files, l.code_lines, l.total_physical_lines,
5677 )
5678 })
5679 .collect();
5680 format!("[{}]", entries.join(","))
5681}
5682
5683fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5685 let entries: Vec<String> = run
5686 .totals_by_language
5687 .iter()
5688 .filter(|l| {
5689 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5690 })
5691 .map(|l| {
5692 let name = json_escape(l.language.display_name());
5693 format!(
5694 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5695 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5696 )
5697 })
5698 .collect();
5699 format!("[{}]", entries.join(","))
5700}
5701
5702fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5704 let entries: Vec<String> = run
5705 .submodule_summaries
5706 .iter()
5707 .map(|s| {
5708 let name = json_escape(&s.name);
5709 format!(
5710 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5711 name,
5712 s.code_lines,
5713 s.comment_lines,
5714 s.blank_lines,
5715 s.total_physical_lines,
5716 s.files_analyzed,
5717 )
5718 })
5719 .collect();
5720 format!("[{}]", entries.join(","))
5721}
5722
5723#[allow(clippy::cast_precision_loss)]
5725fn cov_pct_str(hit: u64, found: u64) -> String {
5726 if found > 0 {
5727 format!("{:.1}", hit as f64 / found as f64 * 100.0)
5728 } else {
5729 String::new()
5730 }
5731}
5732
5733fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5735 if found > 0 {
5736 format!("{hit} / {found}")
5737 } else {
5738 String::new()
5739 }
5740}
5741
5742const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5743 use sloc_core::CocomoMode;
5744 match mode {
5745 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5746 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5747 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5748 }
5749}
5750
5751const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5752 use sloc_core::CocomoMode;
5753 match mode {
5754 CocomoMode::Organic => "Organic",
5755 CocomoMode::SemiDetached => "Semi-detached",
5756 CocomoMode::Embedded => "Embedded",
5757 }
5758}
5759
5760const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5761 use sloc_core::CocomoMode;
5762 match mode {
5763 CocomoMode::Organic => {
5764 "Organic: A small team working on a well-understood project in a familiar \
5765 environment with minimal external constraints. Suited for internal tools, \
5766 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5767 }
5768 CocomoMode::SemiDetached => {
5769 "Semi-detached: A mixed team with varying experience tackling a project with \
5770 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5771 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5772 }
5773 CocomoMode::Embedded => {
5774 "Embedded: Tight hardware, software, or operational constraints requiring \
5775 significant innovation and deep integration work. Typical for real-time control \
5776 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5777 }
5778 }
5779}
5780
5781struct CocomoFields {
5783 has_cocomo: bool,
5784 effort_str: String,
5785 duration_str: String,
5786 staff_str: String,
5787 ksloc_str: String,
5788 mode_label: String,
5789 mode_tooltip: String,
5790}
5791
5792#[allow(clippy::cast_precision_loss)]
5793fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5794 use sloc_core::CocomoMode;
5795 let mode = match mode_str {
5796 "semi_detached" => CocomoMode::SemiDetached,
5797 "embedded" => CocomoMode::Embedded,
5798 _ => CocomoMode::Organic,
5799 };
5800 let (a, b, c, d) = cocomo_coefficients(mode);
5801 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5802 let effort = a * ksloc.powf(b);
5803 let duration = c * effort.powf(d);
5804 let staff = if duration > 0.0 {
5805 effort / duration
5806 } else {
5807 0.0
5808 };
5809 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5810 let mode_label = cocomo_mode_label(mode).to_string();
5811 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5812 if run.summary_totals.code_lines > 0 {
5813 CocomoFields {
5814 has_cocomo: true,
5815 effort_str: round2(effort),
5816 duration_str: round2(duration),
5817 staff_str: round2(staff),
5818 ksloc_str: round2(ksloc),
5819 mode_label,
5820 mode_tooltip,
5821 }
5822 } else {
5823 CocomoFields {
5824 has_cocomo: false,
5825 effort_str: String::new(),
5826 duration_str: String::new(),
5827 staff_str: String::new(),
5828 ksloc_str: String::new(),
5829 mode_label,
5830 mode_tooltip,
5831 }
5832 }
5833}
5834
5835#[allow(clippy::too_many_lines)]
5836#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
5839 run: &AnalysisRun,
5840 artifacts: &RunArtifacts,
5841 run_id: &str,
5842 csp_nonce: &str,
5843 confluence_configured: bool,
5844 server_mode: bool,
5845) -> Response {
5846 let ctx = &artifacts.result_context;
5847 let prev_entry = &ctx.prev_entry;
5848 let prev_scan_count = ctx.prev_scan_count;
5849 let project_path_owned = if ctx.project_path.is_empty() {
5853 run.input_roots.join(", ")
5854 } else {
5855 ctx.project_path.clone()
5856 };
5857 let project_path = &project_path_owned;
5858
5859 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5860 prev.json_path
5861 .as_ref()
5862 .and_then(|p| read_json(p).ok())
5863 .map(|prev_run| compute_delta(&prev_run, run))
5864 });
5865
5866 let files_analyzed = run.per_file_records.len() as u64;
5867 let files_skipped = run.skipped_file_records.len() as u64;
5868 let totals = sum_lang_totals(run);
5869
5870 let DeltaFields {
5871 prev_fa_str,
5872 prev_fs_str,
5873 prev_pl_str,
5874 prev_cl_str,
5875 prev_cml_str,
5876 prev_bl_str,
5877 delta_fa_str,
5878 delta_fa_class,
5879 delta_fs_str,
5880 delta_fs_class,
5881 delta_pl_str,
5882 delta_pl_class,
5883 delta_cl_str,
5884 delta_cl_class,
5885 delta_cml_str,
5886 delta_cml_class,
5887 delta_bl_str,
5888 delta_bl_class,
5889 delta_lines_added,
5890 delta_lines_removed,
5891 delta_lines_net_str,
5892 delta_lines_net_class,
5893 } = compute_delta_fields(
5894 prev_entry.as_ref(),
5895 &totals,
5896 files_analyzed,
5897 files_skipped,
5898 scan_delta.as_ref(),
5899 );
5900
5901 let run_dir = artifacts.output_dir.clone();
5902 let git_branch = run.git_branch.clone();
5903 let git_commit = run.git_commit_short.clone();
5904 let git_commit_long = run.git_commit_long.clone();
5905 let git_author = run.git_commit_author.clone();
5906 let git_commit_url = git_commit_url_for(run);
5907 let git_branch_url = git_branch_url_for(run);
5908 let scan_performed_by = scan_performed_by(run);
5909 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5910 let os_display = format!(
5911 "{} / {}",
5912 run.environment.operating_system, run.environment.architecture
5913 );
5914 let test_count = run.summary_totals.test_count;
5915
5916 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5918 let lsloc = run.summary_totals.lsloc;
5919 let uloc = run.uloc;
5920 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5921 let duplicate_group_count = run.duplicate_groups.len();
5922
5923 let ctx = &artifacts.result_context;
5925 let CocomoFields {
5926 has_cocomo,
5927 effort_str: cocomo_effort_str,
5928 duration_str: cocomo_duration_str,
5929 staff_str: cocomo_staff_str,
5930 ksloc_str: cocomo_ksloc_str,
5931 mode_label: cocomo_mode_label,
5932 mode_tooltip: cocomo_mode_tooltip,
5933 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
5934 let complexity_alert = ctx.complexity_alert;
5935
5936 let template = ResultTemplate {
5937 version: env!("CARGO_PKG_VERSION"),
5938 report_title: run.effective_configuration.reporting.report_title.clone(),
5939 project_path: project_path.clone(),
5940 output_dir: display_path(&artifacts.output_dir),
5941 run_id: run_id.to_owned(),
5942 run_id_short: run_id
5943 .split('-')
5944 .next_back()
5945 .unwrap_or(run_id)
5946 .chars()
5947 .take(7)
5948 .collect(),
5949 files_analyzed,
5950 files_skipped,
5951 physical_lines: totals.physical_lines,
5952 code_lines: totals.code_lines,
5953 comment_lines: totals.comment_lines,
5954 blank_lines: totals.blank_lines,
5955 mixed_lines: totals.mixed_lines,
5956 functions: totals.functions,
5957 classes: totals.classes,
5958 variables: totals.variables,
5959 imports: totals.imports,
5960 html_url: artifacts
5961 .html_path
5962 .as_ref()
5963 .map(|_| format!("/runs/html/{run_id}")),
5964 pdf_url: artifacts
5965 .pdf_path
5966 .as_ref()
5967 .map(|_| format!("/runs/pdf/{run_id}")),
5968 json_url: artifacts
5969 .json_path
5970 .as_ref()
5971 .map(|_| format!("/runs/json/{run_id}")),
5972 html_download_url: artifacts
5973 .html_path
5974 .as_ref()
5975 .map(|_| format!("/runs/html/{run_id}?download=1")),
5976 pdf_download_url: artifacts
5977 .pdf_path
5978 .as_ref()
5979 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
5980 json_download_url: artifacts
5981 .json_path
5982 .as_ref()
5983 .map(|_| format!("/runs/json/{run_id}?download=1")),
5984 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
5985 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
5986 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
5987 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
5988 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
5989 prev_fa_str,
5990 prev_fs_str,
5991 prev_pl_str,
5992 prev_cl_str,
5993 prev_cml_str,
5994 prev_bl_str,
5995 delta_fa_str,
5996 delta_fa_class,
5997 delta_fs_str,
5998 delta_fs_class,
5999 delta_pl_str,
6000 delta_pl_class,
6001 delta_cl_str,
6002 delta_cl_class,
6003 delta_cml_str,
6004 delta_cml_class,
6005 delta_bl_str,
6006 delta_bl_class,
6007 delta_lines_added,
6008 delta_lines_removed,
6009 delta_lines_net_str,
6010 delta_lines_net_class,
6011 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6012 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6013 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6014 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6015 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6016 git_branch,
6017 git_branch_url,
6018 git_commit,
6019 git_commit_long,
6020 git_author,
6021 git_commit_url,
6022 scan_performed_by,
6023 scan_time_display,
6024 os_display,
6025 test_count,
6026 test_assertion_count: run.summary_totals.test_assertion_count,
6027 current_scan_number: prev_scan_count + 1,
6028 prev_scan_count,
6029 submodule_rows: run
6030 .submodule_summaries
6031 .iter()
6032 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6033 .collect(),
6034 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6035 scan_config_url: format!("/runs/scan-config/{run_id}"),
6036 lang_chart_json: build_lang_chart_json(run),
6037 scatter_chart_json: build_scatter_chart_json(run),
6038 semantic_chart_json: build_semantic_chart_json(run),
6039 submodule_chart_json: build_submodule_chart_json(run),
6040 has_submodule_data: !run.submodule_summaries.is_empty(),
6041 has_semantic_data: run
6042 .totals_by_language
6043 .iter()
6044 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6045 csp_nonce: csp_nonce.to_owned(),
6046 confluence_configured,
6047 server_mode,
6048 report_header_footer: run
6049 .effective_configuration
6050 .reporting
6051 .report_header_footer
6052 .clone(),
6053 is_offline: false,
6054 cyclomatic_complexity,
6055 lsloc,
6056 uloc,
6057 dryness_pct_str,
6058 duplicate_group_count,
6059 has_cocomo,
6060 cocomo_effort_str,
6061 cocomo_duration_str,
6062 cocomo_staff_str,
6063 cocomo_ksloc_str,
6064 cocomo_mode_label,
6065 cocomo_mode_tooltip,
6066 complexity_alert,
6067 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6068 cov_line_pct: cov_pct_str(
6069 run.summary_totals.coverage_lines_hit,
6070 run.summary_totals.coverage_lines_found,
6071 ),
6072 cov_fn_pct: cov_pct_str(
6073 run.summary_totals.coverage_functions_hit,
6074 run.summary_totals.coverage_functions_found,
6075 ),
6076 cov_branch_pct: cov_pct_str(
6077 run.summary_totals.coverage_branches_hit,
6078 run.summary_totals.coverage_branches_found,
6079 ),
6080 cov_lines_summary: cov_lines_summary_str(
6081 run.summary_totals.coverage_lines_hit,
6082 run.summary_totals.coverage_lines_found,
6083 ),
6084 };
6085
6086 Html(
6087 template
6088 .render()
6089 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6090 )
6091 .into_response()
6092}
6093
6094fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6095 let slug: String = report_title
6096 .chars()
6097 .map(|c| {
6098 if c.is_alphanumeric() || c == '-' {
6099 c.to_ascii_lowercase()
6100 } else {
6101 '_'
6102 }
6103 })
6104 .collect::<String>()
6105 .split('_')
6106 .filter(|s| !s.is_empty())
6107 .collect::<Vec<_>>()
6108 .join("_");
6109
6110 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6111
6112 if slug.is_empty() {
6113 format!("report_{short_id}.pdf")
6114 } else {
6115 format!("{slug}_{short_id}.pdf")
6116 }
6117}
6118
6119#[derive(Serialize)]
6120struct PdfStatusResponse {
6121 ready: bool,
6122}
6123
6124async fn pdf_status_handler(
6127 State(state): State<AppState>,
6128 AxumPath(run_id): AxumPath<String>,
6129) -> Response {
6130 let pdf_path = {
6131 let registry = state.artifacts.lock().await;
6132 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6133 };
6134 let pdf_path = if pdf_path.is_some() {
6135 pdf_path
6136 } else {
6137 let reg = state.registry.lock().await;
6138 reg.find_by_run_id(&run_id)
6139 .map(recover_artifacts_from_registry)
6140 .and_then(|a| a.pdf_path)
6141 };
6142 let ready = pdf_path.is_some_and(|p| p.exists());
6143 Json(PdfStatusResponse { ready }).into_response()
6144}
6145
6146async fn download_bundle_handler(
6152 State(state): State<AppState>,
6153 AxumPath(run_id): AxumPath<String>,
6154) -> Response {
6155 let output_dir = {
6157 let cache = state.artifacts.lock().await;
6158 cache.get(&run_id).map(|a| a.output_dir.clone())
6159 };
6160 let output_dir = if let Some(d) = output_dir {
6161 d
6162 } else {
6163 let reg = state.registry.lock().await;
6164 match reg.find_by_run_id(&run_id) {
6165 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6166 None => {
6167 return (
6168 StatusCode::NOT_FOUND,
6169 Json(serde_json::json!({"error": "Run not found"})),
6170 )
6171 .into_response();
6172 }
6173 }
6174 };
6175
6176 if !output_dir.exists() {
6177 return (
6178 StatusCode::NOT_FOUND,
6179 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6180 )
6181 .into_response();
6182 }
6183
6184 let run_id_clone = run_id.clone();
6186 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6187 use flate2::{write::GzEncoder, Compression};
6188 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6189 {
6190 let mut tar = tar::Builder::new(&mut enc);
6191 tar.follow_symlinks(false);
6192 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6195 for entry in entries.filter_map(Result::ok) {
6196 let p = entry.path();
6197 if p.is_file() {
6198 let name = p.file_name().unwrap_or_default().to_string_lossy();
6199 let archive_path = format!("{run_id_clone}/{name}");
6200 tar.append_path_with_name(&p, &archive_path)?;
6201 }
6202 }
6203 }
6204 tar.finish()?;
6205 }
6206 Ok(enc.finish()?)
6207 })
6208 .await;
6209
6210 match archive_result {
6211 Ok(Ok(bytes)) => {
6212 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6213 axum::response::Response::builder()
6214 .status(StatusCode::OK)
6215 .header("Content-Type", "application/gzip")
6216 .header(
6217 "Content-Disposition",
6218 format!("attachment; filename=\"{filename}\""),
6219 )
6220 .header("Content-Length", bytes.len().to_string())
6221 .body(axum::body::Body::from(bytes))
6222 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6223 }
6224 Ok(Err(e)) => (
6225 StatusCode::INTERNAL_SERVER_ERROR,
6226 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6227 )
6228 .into_response(),
6229 Err(e) => (
6230 StatusCode::INTERNAL_SERVER_ERROR,
6231 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6232 )
6233 .into_response(),
6234 }
6235}
6236
6237async fn delete_run_handler(
6242 State(state): State<AppState>,
6243 AxumPath(run_id): AxumPath<String>,
6244) -> Response {
6245 let output_dir = {
6247 let mut cache = state.artifacts.lock().await;
6248 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6249 cache.remove(&run_id);
6250 dir
6251 };
6252 let output_dir = if let Some(d) = output_dir {
6253 d
6254 } else {
6255 let reg = state.registry.lock().await;
6256 reg.find_by_run_id(&run_id)
6257 .map(|e| recover_artifacts_from_registry(e).output_dir)
6258 .unwrap_or_default()
6259 };
6260
6261 {
6263 let mut reg = state.registry.lock().await;
6264 reg.entries.retain(|e| e.run_id != run_id);
6265 let _ = reg.save(&state.registry_path);
6266 }
6267
6268 if output_dir.exists() {
6271 match tokio::fs::remove_dir_all(&output_dir).await {
6272 Ok(()) => {}
6273 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6274 Err(e) => {
6275 return (
6276 StatusCode::INTERNAL_SERVER_ERROR,
6277 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6278 )
6279 .into_response();
6280 }
6281 }
6282 }
6283
6284 StatusCode::NO_CONTENT.into_response()
6285}
6286
6287async fn cleanup_runs_handler(
6292 State(state): State<AppState>,
6293 Json(body): Json<serde_json::Value>,
6294) -> Response {
6295 let days = body
6296 .get("older_than_days")
6297 .and_then(serde_json::Value::as_u64)
6298 .unwrap_or(30)
6299 .max(1);
6300
6301 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6302
6303 let expired: Vec<(String, PathBuf)> = {
6305 let reg = state.registry.lock().await;
6306 reg.entries
6307 .iter()
6308 .filter(|e| e.timestamp_utc < cutoff)
6309 .map(|e| {
6310 let arts = recover_artifacts_from_registry(e);
6311 (e.run_id.clone(), arts.output_dir)
6312 })
6313 .collect()
6314 };
6315
6316 let mut deleted = 0usize;
6317 for (run_id, output_dir) in &expired {
6318 state.artifacts.lock().await.remove(run_id);
6320 if output_dir.exists() {
6322 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6323 eprintln!(
6324 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6325 output_dir.display()
6326 );
6327 continue;
6328 }
6329 }
6330 deleted += 1;
6331 }
6332
6333 let expired_ids: std::collections::HashSet<&str> =
6335 expired.iter().map(|(id, _)| id.as_str()).collect();
6336 {
6337 let mut reg = state.registry.lock().await;
6338 reg.entries
6339 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6340 let _ = reg.save(&state.registry_path);
6341 }
6342
6343 Json(serde_json::json!({ "deleted": deleted })).into_response()
6344}
6345
6346fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6349 tokio::spawn(async move {
6350 loop {
6351 let interval_secs = {
6352 let store = state.cleanup_policy.lock().await;
6353 match &store.policy {
6354 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6355 _ => break,
6356 }
6357 };
6358 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6359 let n = run_auto_cleanup(&state).await;
6360 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6361 }
6362 })
6363}
6364
6365fn collect_runs_to_delete(
6366 reg: &ScanRegistry,
6367 max_age_days: Option<u32>,
6368 max_run_count: Option<u32>,
6369) -> std::collections::HashSet<String> {
6370 let mut to_delete = std::collections::HashSet::new();
6371 if let Some(days) = max_age_days {
6372 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6373 for e in ®.entries {
6374 if e.timestamp_utc < cutoff {
6375 to_delete.insert(e.run_id.clone());
6376 }
6377 }
6378 }
6379 if let Some(max_count) = max_run_count {
6380 for e in reg.entries.iter().skip(max_count as usize) {
6382 to_delete.insert(e.run_id.clone());
6383 }
6384 }
6385 to_delete
6386}
6387
6388async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6389 let output_dir = {
6390 let mut cache = state.artifacts.lock().await;
6391 let d = cache.get(run_id).map(|a| a.output_dir.clone());
6392 cache.remove(run_id);
6393 d
6394 };
6395 let output_dir = if let Some(d) = output_dir {
6396 d
6397 } else {
6398 let reg = state.registry.lock().await;
6399 reg.find_by_run_id(run_id)
6400 .map(|e| recover_artifacts_from_registry(e).output_dir)
6401 .unwrap_or_default()
6402 };
6403 if output_dir.exists() {
6404 let _ = tokio::fs::remove_dir_all(&output_dir).await;
6405 }
6406}
6407
6408async fn run_auto_cleanup(state: &AppState) -> u32 {
6412 let (max_age_days, max_run_count) = {
6413 let store = state.cleanup_policy.lock().await;
6414 match &store.policy {
6415 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6416 _ => return 0,
6417 }
6418 };
6419
6420 let to_delete = {
6421 let reg = state.registry.lock().await;
6422 collect_runs_to_delete(®, max_age_days, max_run_count)
6423 };
6424
6425 for run_id in &to_delete {
6426 delete_run_artifacts(state, run_id).await;
6427 }
6428
6429 if !to_delete.is_empty() {
6431 let mut reg = state.registry.lock().await;
6432 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6433 let _ = reg.save(&state.registry_path);
6434 }
6435
6436 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6437 {
6438 let mut store = state.cleanup_policy.lock().await;
6439 store.last_run_at = Some(chrono::Utc::now());
6440 store.last_run_deleted = Some(deleted);
6441 let _ = store.save(&state.cleanup_policy_path);
6442 }
6443 deleted
6444}
6445
6446async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6450 let store = state.cleanup_policy.lock().await;
6451 Json(serde_json::json!({
6452 "policy": store.policy,
6453 "last_run_at": store.last_run_at,
6454 "last_run_deleted": store.last_run_deleted,
6455 }))
6456 .into_response()
6457}
6458
6459async fn api_save_cleanup_policy(
6461 State(state): State<AppState>,
6462 Json(body): Json<CleanupPolicy>,
6463) -> Response {
6464 {
6466 let mut handle = state.cleanup_task_handle.lock().await;
6467 if let Some(h) = handle.take() {
6468 h.abort();
6469 }
6470 }
6471 {
6472 let mut store = state.cleanup_policy.lock().await;
6473 store.policy = Some(body.clone());
6474 if let Err(e) = store.save(&state.cleanup_policy_path) {
6475 return (
6476 StatusCode::INTERNAL_SERVER_ERROR,
6477 Json(serde_json::json!({"error": e.to_string()})),
6478 )
6479 .into_response();
6480 }
6481 }
6482 if body.enabled {
6483 let handle = spawn_cleanup_policy_task(state.clone());
6484 *state.cleanup_task_handle.lock().await = Some(handle);
6485 }
6486 StatusCode::NO_CONTENT.into_response()
6487}
6488
6489async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6491 let deleted = run_auto_cleanup(&state).await;
6492 Json(serde_json::json!({ "deleted": deleted })).into_response()
6493}
6494
6495async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6497 {
6498 let mut handle = state.cleanup_task_handle.lock().await;
6499 if let Some(h) = handle.take() {
6500 h.abort();
6501 }
6502 }
6503 {
6504 let mut store = state.cleanup_policy.lock().await;
6505 store.policy = None;
6506 let _ = store.save(&state.cleanup_policy_path);
6507 }
6508 StatusCode::NO_CONTENT.into_response()
6509}
6510
6511fn swap_inline_chart_js_for_static(html: String) -> String {
6517 let Some(head_end) = html.find("</head>") else {
6518 return html;
6519 };
6520 let Some(script_start) = html[..head_end].rfind("<script") else {
6521 return html;
6522 };
6523 let Some(close_offset) = html[script_start..].find("</script>") else {
6524 return html;
6525 };
6526 let block_end = script_start + close_offset + "</script>".len();
6527 format!(
6528 "{}<script src=\"/static/chart-report.js\"></script>{}",
6529 &html[..script_start],
6530 &html[block_end..]
6531 )
6532}
6533
6534fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6536 let Some(start) = html.find("nonce=\"") else {
6538 return html
6542 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6543 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6544 };
6545 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
6547 return html.to_owned();
6548 };
6549 let old_nonce = &html[value_start..value_start + end_offset];
6550 html.replace(
6551 &format!("nonce=\"{old_nonce}\""),
6552 &format!("nonce=\"{new_nonce}\""),
6553 )
6554}
6555
6556fn serve_html_artifact(
6557 path: &Path,
6558 wants_download: bool,
6559 csp_nonce: &str,
6560 run_id: &str,
6561 server_mode: bool,
6562) -> Response {
6563 match fs::read_to_string(path) {
6564 Ok(raw) => {
6565 let content = patch_html_nonce(&raw, csp_nonce);
6567 if wants_download {
6568 (
6570 [
6571 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6572 (
6573 header::CONTENT_DISPOSITION,
6574 "attachment; filename=report.html",
6575 ),
6576 ],
6577 content,
6578 )
6579 .into_response()
6580 } else {
6581 Html(swap_inline_chart_js_for_static(content)).into_response()
6584 }
6585 }
6586 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6587 let filename = path.file_name().map_or_else(
6588 || "report.html".to_string(),
6589 |n| n.to_string_lossy().into_owned(),
6590 );
6591 let html = LocateFileTemplate {
6592 run_id: run_id.to_owned(),
6593 artifact_type: "html".to_string(),
6594 expected_filename: filename,
6595 server_mode,
6596 csp_nonce: csp_nonce.to_owned(),
6597 version: env!("CARGO_PKG_VERSION"),
6598 }
6599 .render()
6600 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6601 (StatusCode::NOT_FOUND, Html(html)).into_response()
6602 }
6603 Err(err) => {
6604 let filename = path.file_name().map_or_else(
6605 || "report.html".to_string(),
6606 |n| n.to_string_lossy().into_owned(),
6607 );
6608 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6609 let html = ErrorTemplate {
6610 message: msg,
6611 last_report_url: Some("/view-reports".to_string()),
6612 last_report_label: Some("View Reports".to_string()),
6613 run_id: None,
6614 error_code: Some(404),
6615 csp_nonce: csp_nonce.to_owned(),
6616 version: env!("CARGO_PKG_VERSION"),
6617 }
6618 .render()
6619 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6620 (StatusCode::NOT_FOUND, Html(html)).into_response()
6621 }
6622 }
6623}
6624
6625fn serve_pdf_artifact(
6627 path: &Path,
6628 report_title: &str,
6629 run_id: &str,
6630 wants_download: bool,
6631 csp_nonce: &str,
6632) -> Response {
6633 match fs::read(path) {
6634 Ok(bytes) => {
6635 let filename = build_pdf_filename(report_title, run_id);
6636 let disposition = if wants_download {
6637 format!("attachment; filename=\"{filename}\"")
6638 } else {
6639 format!("inline; filename=\"{filename}\"")
6640 };
6641 (
6642 [
6643 (header::CONTENT_TYPE, "application/pdf".to_string()),
6644 (header::CONTENT_DISPOSITION, disposition),
6645 ],
6646 bytes,
6647 )
6648 .into_response()
6649 }
6650 Err(err) => {
6651 let filename = path.file_name().map_or_else(
6652 || "report.pdf".to_string(),
6653 |n| n.to_string_lossy().into_owned(),
6654 );
6655 let msg = format!(
6656 "PDF report '{filename}' could not be read.\n\n\
6657 Error: {err}\n\n\
6658 If you moved or renamed the output folder, the stored path is now stale. \
6659 Use 'Open PDF folder' from the results page to browse the output directory."
6660 );
6661 let html = ErrorTemplate {
6662 message: msg,
6663 last_report_url: Some("/view-reports".to_string()),
6664 last_report_label: Some("View Reports".to_string()),
6665 run_id: Some(run_id.to_owned()),
6666 error_code: Some(404),
6667 csp_nonce: csp_nonce.to_owned(),
6668 version: env!("CARGO_PKG_VERSION"),
6669 }
6670 .render()
6671 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6672 (StatusCode::NOT_FOUND, Html(html)).into_response()
6673 }
6674 }
6675}
6676
6677fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6679 match fs::read(path) {
6680 Ok(bytes) => {
6681 if wants_download {
6682 (
6683 [
6684 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6685 (
6686 header::CONTENT_DISPOSITION,
6687 "attachment; filename=result.json",
6688 ),
6689 ],
6690 bytes,
6691 )
6692 .into_response()
6693 } else {
6694 (
6695 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6696 bytes,
6697 )
6698 .into_response()
6699 }
6700 }
6701 Err(err) => {
6702 let filename = path.file_name().map_or_else(
6703 || "result.json".to_string(),
6704 |n| n.to_string_lossy().into_owned(),
6705 );
6706 let msg = format!(
6707 "JSON result '{filename}' could not be read.\n\n\
6708 Error: {err}\n\n\
6709 If you moved or renamed the output folder, the stored path is now stale. \
6710 Use 'Open JSON folder' from the results page to browse the output directory."
6711 );
6712 let html = ErrorTemplate {
6713 message: msg,
6714 last_report_url: Some("/view-reports".to_string()),
6715 last_report_label: Some("View Reports".to_string()),
6716 run_id: None,
6717 error_code: Some(404),
6718 csp_nonce: csp_nonce.to_owned(),
6719 version: env!("CARGO_PKG_VERSION"),
6720 }
6721 .render()
6722 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6723 (StatusCode::NOT_FOUND, Html(html)).into_response()
6724 }
6725 }
6726}
6727
6728fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6730 let output_dir = entry
6733 .html_path
6734 .as_ref()
6735 .or(entry.json_path.as_ref())
6736 .or(entry.pdf_path.as_ref())
6737 .or(entry.csv_path.as_ref())
6738 .or(entry.xlsx_path.as_ref())
6739 .and_then(|p| {
6740 let parent = p.parent()?;
6741 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6742 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6744 parent.parent().map(PathBuf::from)
6745 } else {
6746 Some(parent.to_path_buf())
6747 }
6748 })
6749 .unwrap_or_default();
6750 let pdf_path = entry.pdf_path.clone().or_else(|| {
6753 let candidate = output_dir.join("report.pdf");
6754 candidate.exists().then_some(candidate)
6755 });
6756 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6760 for dir in &[output_dir.join("excel"), output_dir.clone()] {
6762 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6763 entries
6764 .filter_map(std::result::Result::ok)
6765 .find(|e| {
6766 let n = e.file_name();
6767 let n = n.to_string_lossy();
6768 n.starts_with("report_") && n.ends_with(ext)
6769 })
6770 .map(|e| e.path())
6771 }) {
6772 return Some(p);
6773 }
6774 }
6775 None
6776 };
6777
6778 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6779 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6780 RunArtifacts {
6781 output_dir: output_dir.clone(),
6782 html_path: entry.html_path.clone(),
6783 pdf_path,
6784 json_path: entry.json_path.clone(),
6785 csv_path,
6786 xlsx_path,
6787 scan_config_path: find_scan_config_in_dir(&output_dir),
6788 report_title: entry.project_label.clone(),
6789 result_context: RunResultContext::default(),
6790 }
6791}
6792
6793#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
6795 state: &AppState,
6796 run_id: &str,
6797 csp_nonce: &str,
6798) -> Result<RunArtifacts, Response> {
6799 let cached = state.artifacts.lock().await.get(run_id).cloned();
6800 if let Some(a) = cached {
6801 return Ok(a);
6802 }
6803 let reg = state.registry.lock().await;
6804 if let Some(entry) = reg.find_by_run_id(run_id) {
6805 return Ok(recover_artifacts_from_registry(entry));
6806 }
6807 drop(reg);
6808 let short_id = &run_id[..run_id.len().min(8)];
6809 let hint = if matches!(
6810 run_id,
6811 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6812 ) {
6813 format!(
6814 " The URL format appears to be reversed \u{2014} \
6815 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6816 Use the View Reports page to navigate to your scan."
6817 )
6818 } else {
6819 " The report may have been deleted or the report directory moved. \
6820 Use View Reports to browse your scan history."
6821 .to_string()
6822 };
6823 let error_html = ErrorTemplate {
6824 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6825 last_report_url: Some("/view-reports".to_string()),
6826 last_report_label: Some("View Reports".to_string()),
6827 run_id: None,
6828 error_code: Some(404),
6829 csp_nonce: csp_nonce.to_owned(),
6830 version: env!("CARGO_PKG_VERSION"),
6831 }
6832 .render()
6833 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6834 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6835}
6836
6837async fn resolve_or_queue_pdf(
6842 state: &AppState,
6843 pdf_path: Option<PathBuf>,
6844 json_path: Option<PathBuf>,
6845 output_dir: PathBuf,
6846 run_id: &str,
6847 report_title: &str,
6848 csp_nonce: &str,
6849) -> Result<PathBuf, Response> {
6850 if let Some(p) = pdf_path {
6851 return Ok(p);
6852 }
6853 let Some(json_src) = json_path.filter(|p| p.exists()) else {
6854 let msg = "PDF report was not generated for this run. \
6855 Re-run the analysis with PDF output enabled."
6856 .to_string();
6857 let html = ErrorTemplate {
6858 message: msg,
6859 last_report_url: Some(format!("/runs/html/{run_id}")),
6860 last_report_label: Some("View HTML Report".to_string()),
6861 run_id: Some(run_id.to_string()),
6862 error_code: Some(404),
6863 csp_nonce: csp_nonce.to_string(),
6864 version: env!("CARGO_PKG_VERSION"),
6865 }
6866 .render()
6867 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6868 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6869 };
6870 let pdf_filename = build_pdf_filename(report_title, run_id);
6871 let pdf_dest = output_dir.join(&pdf_filename);
6872 if !pdf_dest.exists() {
6873 {
6875 let mut map = state.artifacts.lock().await;
6876 if let Some(entry) = map.get_mut(run_id) {
6877 entry.pdf_path = Some(pdf_dest.clone());
6878 }
6879 }
6880 {
6881 let mut reg = state.registry.lock().await;
6882 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6883 e.pdf_path = Some(pdf_dest.clone());
6884 }
6885 let _ = reg.save(&state.registry_path);
6886 }
6887 spawn_native_pdf_background(
6888 json_src,
6889 pdf_dest.clone(),
6890 run_id.to_string(),
6891 state.artifacts.clone(),
6892 );
6893 }
6894 Ok(pdf_dest)
6895}
6896
6897fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6899 let html = format!(
6900 "<!doctype html><html lang=\"en\"><head>\
6901 <meta charset=utf-8>\
6902 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6903 <meta http-equiv=\"refresh\" content=\"5\">\
6904 <title>OxideSLOC | Generating PDF\u{2026}</title>\
6905 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6906 <style nonce=\"{csp_nonce}\">\
6907 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6908 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6909 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6910 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6911 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6912 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6913 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6914 background:var(--bg);color:var(--text);}}\
6915 .top-nav{{position:sticky;top:0;z-index:30;\
6916 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6917 border-bottom:1px solid rgba(255,255,255,0.12);\
6918 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6919 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6920 min-height:56px;display:flex;align-items:center;gap:14px;}}\
6921 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6922 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6923 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6924 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6925 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6926 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6927 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6928 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6929 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6930 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6931 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6932 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6933 justify-content:center;min-height:38px;border-radius:999px;\
6934 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6935 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6936 .theme-toggle .icon-sun{{display:none;}}\
6937 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6938 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6939 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6940 display:flex;align-items:center;justify-content:center;\
6941 min-height:calc(100vh - 56px);}}\
6942 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6943 .panel{{background:var(--surface);border:1px solid var(--line);\
6944 border-radius:var(--radius);box-shadow:var(--shadow);\
6945 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6946 .spin-ring{{width:56px;height:56px;border-radius:50%;\
6947 border:5px solid var(--line);border-top-color:var(--oxide-2);\
6948 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6949 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6950 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6951 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6952 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6953 min-height:42px;padding:0 20px;border-radius:14px;\
6954 border:1px solid var(--line-strong);text-decoration:none;\
6955 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6956 .back-link:hover{{background:var(--line);}}\
6957 </style></head>\
6958 <body>\
6959 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6960 <a class=\"brand\" href=\"/\">\
6961 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6962 <div class=\"brand-copy\">\
6963 <div class=\"brand-title\">OxideSLOC</div>\
6964 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6965 </div>\
6966 </a>\
6967 <div class=\"nav-right\">\
6968 <a class=\"nav-pill\" href=\"/\">Home</a>\
6969 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6970 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6971 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6972 <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>\
6973 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6974 <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>\
6975 </button>\
6976 </div>\
6977 </div></div>\
6978 <div class=\"page\"><div class=\"panel\">\
6979 <div class=\"spin-ring\"></div>\
6980 <h1>Generating PDF\u{2026}</h1>\
6981 <p>The PDF is being generated from the scan results.<br>\
6982 This page refreshes automatically \u{2014} usually a few seconds.</p>\
6983 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
6984 </div></div>\
6985 <script nonce=\"{csp_nonce}\">\
6986 (function(){{\
6987 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
6988 if(s===\"dark\")b.classList.add(\"dark-theme\");\
6989 var t=document.getElementById(\"theme-toggle\");\
6990 if(t)t.addEventListener(\"click\",function(){{\
6991 var d=b.classList.toggle(\"dark-theme\");\
6992 localStorage.setItem(k,d?\"dark\":\"light\");\
6993 }});\
6994 }})();\
6995 </script>\
6996 </body></html>"
6997 );
6998 Html(html).into_response()
6999}
7000
7001fn render_error_artifact_html(
7003 message: String,
7004 last_report_url: Option<String>,
7005 last_report_label: Option<String>,
7006 run_id: Option<String>,
7007 error_code: Option<u16>,
7008 csp_nonce: &str,
7009) -> String {
7010 ErrorTemplate {
7011 message,
7012 last_report_url,
7013 last_report_label,
7014 run_id,
7015 error_code,
7016 csp_nonce: csp_nonce.to_owned(),
7017 version: env!("CARGO_PKG_VERSION"),
7018 }
7019 .render()
7020 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7021}
7022
7023fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7025 fs::read(path).map_or_else(
7026 |_| StatusCode::NOT_FOUND.into_response(),
7027 |bytes| {
7028 let filename = path.file_name().map_or_else(
7029 || fallback_filename.to_string(),
7030 |n| n.to_string_lossy().into_owned(),
7031 );
7032 (
7033 [
7034 (header::CONTENT_TYPE, content_type.to_string()),
7035 (
7036 header::CONTENT_DISPOSITION,
7037 format!("attachment; filename=\"{filename}\""),
7038 ),
7039 ],
7040 bytes,
7041 )
7042 .into_response()
7043 },
7044 )
7045}
7046
7047fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7048 let Some(path) = csv_path else {
7049 let html = render_error_artifact_html(
7050 "CSV report was not generated for this run, or was not recorded in \
7051 the scan registry."
7052 .to_string(),
7053 Some(format!("/runs/html/{run_id}")),
7054 Some("View HTML Report".to_string()),
7055 Some(run_id.to_string()),
7056 Some(404),
7057 csp_nonce,
7058 );
7059 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7060 };
7061 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7062}
7063
7064fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7065 let Some(path) = xlsx_path else {
7066 let html = render_error_artifact_html(
7067 "Excel report was not generated for this run, or was not recorded in \
7068 the scan registry."
7069 .to_string(),
7070 Some(format!("/runs/html/{run_id}")),
7071 Some("View HTML Report".to_string()),
7072 Some(run_id.to_string()),
7073 Some(404),
7074 csp_nonce,
7075 );
7076 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7077 };
7078 serve_binary_download(
7079 &path,
7080 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7081 "report.xlsx",
7082 )
7083}
7084
7085fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7086 let path = artifact_set
7087 .scan_config_path
7088 .as_deref()
7089 .map(std::path::Path::to_path_buf)
7090 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7091 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7092 fs::read(&path).map_or_else(
7093 |_| StatusCode::NOT_FOUND.into_response(),
7094 |bytes| {
7095 (
7096 [
7097 (
7098 header::CONTENT_TYPE,
7099 "application/json; charset=utf-8".to_string(),
7100 ),
7101 (
7102 header::CONTENT_DISPOSITION,
7103 "attachment; filename=\"scan-config.json\"".to_string(),
7104 ),
7105 ],
7106 bytes,
7107 )
7108 .into_response()
7109 },
7110 )
7111}
7112
7113async fn serve_submodule_pdf_arm(
7118 artifact: &str,
7119 artifact_set: RunArtifacts,
7120 wants_download: bool,
7121 run_id: &str,
7122 csp_nonce: &str,
7123) -> Response {
7124 let base = artifact.trim_end_matches("_pdf");
7126 let sub_dir = artifact_set.output_dir.join("submodules");
7127 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7128
7129 if !pdf_path.exists() {
7130 let derived_safe = base.trim_start_matches("sub_");
7132 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7133 let parent_run = read_json(jp).ok()?;
7134 let sub = parent_run
7135 .submodule_summaries
7136 .iter()
7137 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7138 .clone();
7139 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7140 Some((parent_run, sub, parent_path))
7141 });
7142
7143 if let Some((parent_run, sub, parent_path)) = rebuilt {
7144 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7145 let pp = pdf_path.clone();
7146 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7147 }
7148 }
7149
7150 if !pdf_path.exists() {
7151 let html = render_error_artifact_html(
7152 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7153 enabled."
7154 .to_string(),
7155 Some("/view-reports".to_string()),
7156 Some("View Reports".to_string()),
7157 Some(run_id.to_string()),
7158 Some(404),
7159 csp_nonce,
7160 );
7161 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7162 }
7163
7164 serve_pdf_artifact(
7165 &pdf_path,
7166 &artifact_set.report_title,
7167 run_id,
7168 wants_download,
7169 csp_nonce,
7170 )
7171}
7172
7173fn serve_submodule_arm(
7174 artifact: &str,
7175 artifact_set: &RunArtifacts,
7176 wants_download: bool,
7177 csp_nonce: &str,
7178 run_id: &str,
7179 server_mode: bool,
7180) -> Response {
7181 if artifact.len() > 128
7182 || !artifact
7183 .chars()
7184 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7185 {
7186 return StatusCode::BAD_REQUEST.into_response();
7187 }
7188 let filename = format!("{artifact}.html");
7189 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7191 let path = if new_layout.exists() {
7192 new_layout
7193 } else {
7194 artifact_set.output_dir.join(&filename)
7195 };
7196 if !path.exists() {
7197 let html = render_error_artifact_html(
7198 format!(
7199 "Sub-report '{artifact}' was not found in the run directory.\n\
7200 Re-run the analysis with 'Detect and separate git submodules' \
7201 and HTML output enabled."
7202 ),
7203 Some("/view-reports".to_string()),
7204 Some("View Reports".to_string()),
7205 Some(run_id.to_string()),
7206 Some(404),
7207 csp_nonce,
7208 );
7209 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7210 }
7211 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7212}
7213
7214async fn serve_pdf_arm(
7215 state: &AppState,
7216 artifact_set: RunArtifacts,
7217 wants_download: bool,
7218 run_id: &str,
7219 csp_nonce: &str,
7220) -> Response {
7221 let report_title = artifact_set.report_title.clone();
7222 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7223 let stale_html_name = artifact_set
7224 .html_path
7225 .as_deref()
7226 .and_then(|p| p.file_name())
7227 .map(|n| n.to_string_lossy().into_owned());
7228 let path = match resolve_or_queue_pdf(
7229 state,
7230 artifact_set.pdf_path,
7231 artifact_set.json_path.clone(),
7232 artifact_set.output_dir.clone(),
7233 run_id,
7234 &report_title,
7235 csp_nonce,
7236 )
7237 .await
7238 {
7239 Ok(p) => p,
7240 Err(r) => return r,
7241 };
7242 if !path.exists() {
7243 if had_pdf_in_registry {
7247 if let Some(expected_filename) = stale_html_name {
7248 let html = LocateFileTemplate {
7249 run_id: run_id.to_string(),
7250 artifact_type: "pdf".to_string(),
7251 expected_filename,
7252 server_mode: state.server_mode,
7253 csp_nonce: csp_nonce.to_string(),
7254 version: env!("CARGO_PKG_VERSION"),
7255 }
7256 .render()
7257 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7258 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7259 }
7260 }
7261 return pdf_generating_response(run_id, csp_nonce);
7262 }
7263 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7264}
7265
7266async fn artifact_handler(
7267 State(state): State<AppState>,
7268 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7269 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7270 Query(query): Query<ArtifactQuery>,
7271) -> Response {
7272 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7273 Ok(a) => a,
7274 Err(r) => return r,
7275 };
7276
7277 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7278
7279 match artifact.as_str() {
7280 "html" => {
7281 let Some(path) = artifact_set.html_path else {
7282 return StatusCode::NOT_FOUND.into_response();
7283 };
7284 serve_html_artifact(
7285 &path,
7286 wants_download,
7287 &csp_nonce,
7288 &run_id,
7289 state.server_mode,
7290 )
7291 }
7292 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7293 "json" => {
7294 let Some(path) = artifact_set.json_path else {
7295 let html = render_error_artifact_html(
7296 "JSON result was not generated for this run, or was not recorded in \
7297 the scan registry. Re-run the analysis with JSON output enabled."
7298 .to_string(),
7299 Some("/view-reports".to_string()),
7300 Some("View Reports".to_string()),
7301 Some(run_id.clone()),
7302 Some(404),
7303 &csp_nonce,
7304 );
7305 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7306 };
7307 serve_json_artifact(&path, wants_download, &csp_nonce)
7308 }
7309 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7310 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7311 "scan-config" => serve_scan_config_arm(&artifact_set),
7312 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7313 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7314 .await
7315 }
7316 _ if artifact.starts_with("sub_") => serve_submodule_arm(
7317 &artifact,
7318 &artifact_set,
7319 wants_download,
7320 &csp_nonce,
7321 &run_id,
7322 state.server_mode,
7323 ),
7324 _ => StatusCode::NOT_FOUND.into_response(),
7325 }
7326}
7327
7328struct SubmoduleLinkRow {
7331 name: String,
7332 url: String,
7333}
7334
7335struct HistoryEntryRow {
7336 run_id: String,
7337 run_id_short: String,
7338 timestamp: String,
7339 timestamp_utc_ms: i64,
7340 project_label: String,
7341 project_path: String,
7342 files_analyzed: u64,
7343 files_skipped: u64,
7344 code_lines: u64,
7345 comment_lines: u64,
7346 blank_lines: u64,
7347 total_physical_lines: u64,
7348 functions: u64,
7349 classes: u64,
7350 variables: u64,
7351 imports: u64,
7352 test_count: u64,
7353 git_branch: String,
7354 git_commit: String,
7355 git_commit_long: String,
7357 has_html: bool,
7358 has_json: bool,
7359 has_pdf: bool,
7360 submodule_links: Vec<SubmoduleLinkRow>,
7361 submodule_names_csv: String,
7363}
7364
7365fn nth_weekday_of_month(
7367 year: i32,
7368 month: u32,
7369 weekday: chrono::Weekday,
7370 n: u32,
7371) -> chrono::NaiveDate {
7372 use chrono::Datelike;
7373 let mut count = 0u32;
7374 let mut day = 1u32;
7375 loop {
7376 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7377 if d.weekday() == weekday {
7378 count += 1;
7379 if count == n {
7380 return d;
7381 }
7382 }
7383 day += 1;
7384 }
7385}
7386
7387fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7391 use chrono::{Datelike, TimeZone};
7392 let year = dt.year();
7393 let dst_start = chrono::Utc.from_utc_datetime(
7394 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7395 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7396 );
7397 let dst_end = chrono::Utc.from_utc_datetime(
7398 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7399 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7400 );
7401 dt >= dst_start && dt < dst_end
7402}
7403
7404fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7405 if is_pacific_dst(dt) {
7406 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7407 .format("%Y-%m-%d %H:%M PDT")
7408 .to_string()
7409 } else {
7410 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7411 .format("%Y-%m-%d %H:%M PST")
7412 .to_string()
7413 }
7414}
7415
7416fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7418 let (offset, tz) = if is_pacific_dst(dt) {
7419 (
7420 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7421 "PDT",
7422 )
7423 } else {
7424 (
7425 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7426 "PST",
7427 )
7428 };
7429 format!(
7430 "{} {tz}",
7431 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7432 )
7433}
7434
7435fn fmt_git_date(iso: &str) -> Option<String> {
7436 chrono::DateTime::parse_from_rfc3339(iso)
7437 .ok()
7438 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7439}
7440
7441fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7450 use std::io::{Read, Seek, SeekFrom};
7451 if short.is_empty() {
7452 return None;
7453 }
7454 let len = std::fs::metadata(path).ok()?.len();
7455 const TAIL: u64 = 4 * 1024 * 1024; let start = len.saturating_sub(TAIL);
7457 let mut file = std::fs::File::open(path).ok()?;
7458 file.seek(SeekFrom::Start(start)).ok()?;
7459 let mut buf = Vec::new();
7460 file.read_to_end(&mut buf).ok()?;
7461 let text = String::from_utf8_lossy(&buf);
7462 let short_lower = short.to_ascii_lowercase();
7463 let key = "\"git_commit_long\"";
7464 let mut found: Option<String> = None;
7465 let mut cursor = 0usize;
7466 while let Some(idx) = text[cursor..].find(key) {
7467 let after_key = cursor + idx + key.len();
7468 cursor = after_key;
7469 let rest = &text[after_key..];
7470 let Some(colon) = rest.find(':') else { break };
7471 let value_region = rest[colon + 1..].trim_start();
7472 if let Some(open) = value_region.strip_prefix('"') {
7474 if let Some(close) = open.find('"') {
7475 let val = &open[..close];
7476 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7477 found = Some(val.to_string());
7478 }
7479 }
7480 }
7481 }
7482 found
7483}
7484
7485fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7486 reg.entries
7487 .iter()
7488 .map(|e| {
7489 let submodule_links = {
7490 let mut links: Vec<SubmoduleLinkRow> = vec![];
7491 let sub_dir = e
7492 .html_path
7493 .as_ref()
7494 .and_then(|p| p.parent())
7495 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7496 if let Some(dir) = sub_dir {
7497 if let Ok(rd) = std::fs::read_dir(dir) {
7498 for entry_res in rd.flatten() {
7499 let fname = entry_res.file_name();
7500 let fname_str = fname.to_string_lossy();
7501 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7502 let stem = &fname_str[..fname_str.len() - 5];
7503 let display = stem[4..].replace('-', " ");
7504 links.push(SubmoduleLinkRow {
7505 name: display,
7506 url: format!("/runs/{stem}/{}", e.run_id),
7507 });
7508 }
7509 }
7510 }
7511 }
7512 links.sort_by(|a, b| a.name.cmp(&b.name));
7513 links
7514 };
7515 let submodule_names_csv = submodule_links
7516 .iter()
7517 .map(|l| l.name.as_str())
7518 .collect::<Vec<_>>()
7519 .join(",");
7520 HistoryEntryRow {
7521 run_id: e.run_id.clone(),
7522 run_id_short: e
7523 .run_id
7524 .split('-')
7525 .next_back()
7526 .unwrap_or(&e.run_id)
7527 .chars()
7528 .take(7)
7529 .collect(),
7530 timestamp: fmt_la_time(e.timestamp_utc),
7531 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7532 project_label: e.project_label.clone(),
7533 project_path: e
7534 .input_roots
7535 .first()
7536 .map(|s| sanitize_path_str(s))
7537 .unwrap_or_default(),
7538 files_analyzed: e.summary.files_analyzed,
7539 files_skipped: e.summary.files_skipped,
7540 code_lines: e.summary.code_lines,
7541 comment_lines: e.summary.comment_lines,
7542 blank_lines: e.summary.blank_lines,
7543 total_physical_lines: e.summary.total_physical_lines,
7544 functions: e.summary.functions,
7545 classes: e.summary.classes,
7546 variables: e.summary.variables,
7547 imports: e.summary.imports,
7548 test_count: e.summary.test_count,
7549 git_branch: e.git_branch.clone().unwrap_or_default(),
7550 git_commit: e.git_commit.clone().unwrap_or_default(),
7551 git_commit_long: {
7552 let short = e.git_commit.clone().unwrap_or_default();
7553 e.git_commit_long
7554 .clone()
7555 .filter(|s| !s.is_empty())
7556 .or_else(|| {
7557 e.json_path
7558 .as_ref()
7559 .and_then(|p| extract_long_commit_from_json(p, &short))
7560 })
7561 .unwrap_or(short)
7562 },
7563 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7564 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7565 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7566 submodule_links,
7567 submodule_names_csv,
7568 }
7569 })
7570 .collect()
7571}
7572
7573#[derive(Deserialize, Default)]
7574struct HistoryQuery {
7575 linked: Option<String>,
7576 error: Option<String>,
7577}
7578
7579async fn history_handler(
7580 State(state): State<AppState>,
7581 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7582 Query(query): Query<HistoryQuery>,
7583) -> impl IntoResponse {
7584 auto_scan_watched_dirs(&state).await;
7586 let watched_dirs: Vec<String> = {
7587 let wd = state.watched_dirs.lock().await;
7588 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7589 };
7590 let mut entries = {
7591 let reg = state.registry.lock().await;
7592 make_history_rows(®)
7593 };
7594 entries.retain(|e| e.has_html);
7595 let total_scans = entries.len();
7596 let linked_count = query
7597 .linked
7598 .as_deref()
7599 .and_then(|s| s.parse::<usize>().ok())
7600 .unwrap_or(0);
7601 let browse_error = query.error.filter(|s| !s.is_empty());
7602 let template = HistoryTemplate {
7603 version: env!("CARGO_PKG_VERSION"),
7604 entries,
7605 total_scans,
7606 linked_count,
7607 browse_error,
7608 watched_dirs,
7609 csp_nonce,
7610 server_mode: state.server_mode,
7611 };
7612 Html(
7613 template
7614 .render()
7615 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7616 )
7617 .into_response()
7618}
7619
7620async fn compare_select_handler(
7621 State(state): State<AppState>,
7622 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7623) -> impl IntoResponse {
7624 auto_scan_watched_dirs(&state).await;
7625 let watched_dirs: Vec<String> = {
7626 let wd = state.watched_dirs.lock().await;
7627 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7628 };
7629 let mut entries = {
7630 let reg = state.registry.lock().await;
7631 make_history_rows(®)
7632 };
7633 entries.retain(|e| e.has_json);
7634 let total_scans = entries.len();
7635 let template = CompareSelectTemplate {
7636 version: env!("CARGO_PKG_VERSION"),
7637 entries,
7638 total_scans,
7639 watched_dirs,
7640 csp_nonce,
7641 server_mode: state.server_mode,
7642 };
7643 Html(
7644 template
7645 .render()
7646 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7647 )
7648 .into_response()
7649}
7650
7651#[derive(Deserialize, Default)]
7654struct CompareQuery {
7655 a: Option<String>,
7656 b: Option<String>,
7657 sub: Option<String>,
7659 scope: Option<String>,
7661}
7662
7663struct CompareFileDeltaRow {
7664 relative_path: String,
7665 language: String,
7666 status: String,
7667 baseline_code: i64,
7668 current_code: i64,
7669 baseline_code_display: String,
7670 current_code_display: String,
7671 code_delta_str: String,
7672 code_delta_class: String,
7673 comment_delta_str: String,
7674 comment_delta_class: String,
7675 total_delta_str: String,
7676 total_delta_class: String,
7677}
7678
7679fn recompute_summary_from_records(run: &mut AnalysisRun) {
7682 let mut totals = SummaryTotals::default();
7683 for r in &run.per_file_records {
7684 if r.language.is_some() {
7685 totals.files_analyzed += 1;
7686 }
7687 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7688 totals.code_lines += r.effective_counts.code_lines;
7689 totals.comment_lines += r.effective_counts.comment_lines;
7690 totals.blank_lines += r.effective_counts.blank_lines;
7691 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7692 totals.functions += r.raw_line_categories.functions;
7693 totals.classes += r.raw_line_categories.classes;
7694 totals.variables += r.raw_line_categories.variables;
7695 totals.imports += r.raw_line_categories.imports;
7696 totals.test_count += r.raw_line_categories.test_count;
7697 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7698 totals.test_suite_count += r.raw_line_categories.test_suite_count;
7699 if let Some(cov) = &r.coverage {
7700 totals.coverage_lines_found += u64::from(cov.lines_found);
7701 totals.coverage_lines_hit += u64::from(cov.lines_hit);
7702 totals.coverage_functions_found += u64::from(cov.functions_found);
7703 totals.coverage_functions_hit += u64::from(cov.functions_hit);
7704 totals.coverage_branches_found += u64::from(cov.branches_found);
7705 totals.coverage_branches_hit += u64::from(cov.branches_hit);
7706 }
7707 }
7708 totals.files_considered = totals.files_analyzed;
7709 run.summary_totals = totals;
7710}
7711
7712fn fmt_delta(n: i64) -> String {
7713 if n > 0 {
7714 format!("+{n}")
7715 } else {
7716 format!("{n}")
7717 }
7718}
7719
7720fn delta_class(n: i64) -> &'static str {
7721 use std::cmp::Ordering;
7722 match n.cmp(&0) {
7723 Ordering::Greater => "pos",
7724 Ordering::Less => "neg",
7725 Ordering::Equal => "zero",
7726 }
7727}
7728
7729#[allow(clippy::cast_precision_loss)]
7731fn fmt_pct(delta: i64, baseline: u64) -> String {
7732 if baseline == 0 {
7733 return "—".to_string();
7734 }
7735 #[allow(clippy::cast_precision_loss)]
7736 let pct = (delta as f64 / baseline as f64) * 100.0;
7737 if pct > 0.049 {
7738 format!("+{pct:.1}%")
7739 } else if pct < -0.049 {
7740 format!("{pct:.1}%")
7741 } else {
7742 "±0%".to_string()
7743 }
7744}
7745
7746fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7748 prev.map_or_else(
7749 || ("—".to_string(), "na"),
7750 |p| {
7751 #[allow(clippy::cast_possible_wrap)]
7752 let d = curr as i64 - p as i64;
7753 (fmt_delta(d), delta_class(d))
7754 },
7755 )
7756}
7757
7758#[allow(clippy::result_large_err)] fn load_scan_for_compare(
7760 json_path: &std::path::Path,
7761 scan_label: &str,
7762 run_id: &str,
7763 server_mode: bool,
7764 compare_url: &str,
7765 csp_nonce: &str,
7766) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7767 match read_json(json_path) {
7768 Ok(r) => Ok(r),
7769 Err(e) => {
7770 if server_mode {
7771 let html = ErrorTemplate {
7772 message: format!(
7773 "Could not load {scan_label} scan data. The scan output folder may have \
7774 been moved, renamed, or deleted. Re-running the analysis will create \
7775 fresh comparison data."
7776 ),
7777 last_report_url: Some("/compare-scans".to_string()),
7778 last_report_label: Some("Compare Scans".to_string()),
7779 run_id: Some(run_id.to_owned()),
7780 error_code: Some(404),
7781 csp_nonce: csp_nonce.to_owned(),
7782 version: env!("CARGO_PKG_VERSION"),
7783 }
7784 .render()
7785 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7786 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7787 }
7788 let msg = format!(
7789 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7790 json_path.display()
7791 );
7792 let folder_hint = output_folder_hint(json_path);
7793 Err(missing_scan_relocate_response(
7794 &msg,
7795 run_id,
7796 &folder_hint,
7797 compare_url,
7798 false,
7799 csp_nonce,
7800 ))
7801 }
7802 }
7803}
7804
7805struct ChurnStats {
7806 new_scope: bool,
7807 scope_flag: bool,
7808 churn_rate_str: String,
7809 churn_rate_class: String,
7810}
7811
7812fn compute_churn_stats(
7813 baseline_code: u64,
7814 current_code: u64,
7815 lines_added: i64,
7816 lines_removed: i64,
7817) -> ChurnStats {
7818 let new_scope = baseline_code == 0 && current_code > 0;
7819 #[allow(clippy::cast_precision_loss)]
7820 let churn_pct = if baseline_code > 0 {
7821 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7822 } else {
7823 0.0
7824 };
7825 #[allow(clippy::cast_precision_loss)]
7826 let scope_flag =
7827 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7828 let churn_rate_str = if new_scope {
7829 "New".to_string()
7830 } else if baseline_code > 0 {
7831 format!("{churn_pct:.1}%")
7832 } else {
7833 "—".to_string()
7834 };
7835 let churn_rate_class = if new_scope || churn_pct > 20.0 {
7836 "high".to_string()
7837 } else if churn_pct > 5.0 {
7838 "med".to_string()
7839 } else {
7840 "low".to_string()
7841 };
7842 ChurnStats {
7843 new_scope,
7844 scope_flag,
7845 churn_rate_str,
7846 churn_rate_class,
7847 }
7848}
7849
7850fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7854 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7855 if !has_data {
7856 return String::new();
7857 }
7858 let base_str = s
7859 .baseline_coverage_line_pct
7860 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7861 let curr_str = s
7862 .current_coverage_line_pct
7863 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7864 let (delta_str, cls) = match s.coverage_line_pct_delta {
7865 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7866 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7867 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7868 None => ("\u{2014}".into(), "zero"),
7869 };
7870 format!(
7871 r#"<div class="delta-card">
7872 <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>
7873 <div class="delta-card-label">Line coverage</div>
7874 <div class="delta-card-from">Before: {base_str}</div>
7875 <div class="delta-card-to">{curr_str}</div>
7876 <span class="delta-card-change {cls}">{delta_str}</span>
7877 </div>"#
7878 )
7879}
7880
7881#[allow(clippy::ref_option)]
7883fn narrow_run_pair_by_scope(
7884 mut baseline: AnalysisRun,
7885 mut current: AnalysisRun,
7886 active_sub: &Option<String>,
7887 super_scope: bool,
7888) -> (AnalysisRun, AnalysisRun) {
7889 if let Some(ref sub_name) = active_sub {
7890 baseline
7891 .per_file_records
7892 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7893 current
7894 .per_file_records
7895 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7896 recompute_summary_from_records(&mut baseline);
7897 recompute_summary_from_records(&mut current);
7898 } else if super_scope {
7899 baseline.per_file_records.retain(|f| f.submodule.is_none());
7900 current.per_file_records.retain(|f| f.submodule.is_none());
7901 recompute_summary_from_records(&mut baseline);
7902 recompute_summary_from_records(&mut current);
7903 }
7904 (baseline, current)
7905}
7906
7907#[allow(clippy::ref_option)]
7909fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7910 if let Some(ref sub_name) = active_sub {
7911 for run in runs.iter_mut() {
7912 run.per_file_records
7913 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7914 recompute_summary_from_records(run);
7915 }
7916 } else if super_scope {
7917 for run in runs.iter_mut() {
7918 run.per_file_records.retain(|f| f.submodule.is_none());
7919 recompute_summary_from_records(run);
7920 }
7921 }
7922}
7923
7924#[allow(clippy::too_many_lines)]
7925async fn compare_handler(
7926 State(state): State<AppState>,
7927 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7928 Query(query): Query<CompareQuery>,
7929) -> impl IntoResponse {
7930 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7933 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7934 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7935 };
7936
7937 let (maybe_a, maybe_b) = {
7938 let reg = state.registry.lock().await;
7939 (
7940 reg.find_by_run_id(&run_id_a).cloned(),
7941 reg.find_by_run_id(&run_id_b).cloned(),
7942 )
7943 };
7944
7945 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7946 let html = ErrorTemplate {
7947 message: "One or both run IDs were not found in scan history. \
7948 The runs may have been deleted or the registry may have been reset."
7949 .to_string(),
7950 last_report_url: Some("/compare-scans".to_string()),
7951 last_report_label: Some("Compare Scans".to_string()),
7952 run_id: None,
7953 error_code: None,
7954 csp_nonce: csp_nonce.clone(),
7955 version: env!("CARGO_PKG_VERSION"),
7956 }
7957 .render()
7958 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7959 return Html(html).into_response();
7960 };
7961
7962 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7964 (entry_a, entry_b)
7965 } else {
7966 (entry_b, entry_a)
7967 };
7968
7969 if baseline_entry.run_id != run_id_a {
7973 let canonical = format!(
7974 "/compare?a={}&b={}",
7975 baseline_entry.run_id, current_entry.run_id
7976 );
7977 return axum::response::Redirect::to(&canonical).into_response();
7978 }
7979
7980 let (Some(base_json), Some(curr_json)) = (
7981 baseline_entry.json_path.as_ref(),
7982 current_entry.json_path.as_ref(),
7983 ) else {
7984 let html = ErrorTemplate {
7985 message: "Full comparison requires JSON scan data, which was not saved for one or \
7986 both of these runs. JSON is now always saved for new scans — re-run the \
7987 affected projects to enable comparisons."
7988 .to_string(),
7989 last_report_url: Some("/compare-scans".to_string()),
7990 last_report_label: Some("Compare Scans".to_string()),
7991 run_id: None,
7992 error_code: None,
7993 csp_nonce: csp_nonce.clone(),
7994 version: env!("CARGO_PKG_VERSION"),
7995 }
7996 .render()
7997 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
7998 return Html(html).into_response();
7999 };
8000
8001 let compare_url = format!(
8002 "/compare?a={}&b={}",
8003 baseline_entry.run_id, current_entry.run_id
8004 );
8005
8006 let baseline_run = match load_scan_for_compare(
8007 base_json,
8008 "baseline",
8009 &baseline_entry.run_id,
8010 state.server_mode,
8011 &compare_url,
8012 &csp_nonce,
8013 ) {
8014 Ok(r) => r,
8015 Err(resp) => return resp,
8016 };
8017 let current_run = match load_scan_for_compare(
8018 curr_json,
8019 "current",
8020 ¤t_entry.run_id,
8021 state.server_mode,
8022 &compare_url,
8023 &csp_nonce,
8024 ) {
8025 Ok(r) => r,
8026 Err(resp) => return resp,
8027 };
8028
8029 let active_submodule = query.sub.clone();
8030 let super_scope_active = query.scope.as_deref() == Some("super");
8031
8032 let submodule_options = baseline_run
8033 .submodule_summaries
8034 .iter()
8035 .chain(current_run.submodule_summaries.iter())
8036 .map(|s| s.name.clone())
8037 .collect::<std::collections::BTreeSet<_>>()
8038 .into_iter()
8039 .collect::<Vec<_>>();
8040 let has_any_submodule_data = !submodule_options.is_empty();
8041
8042 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8044 baseline_run,
8045 current_run,
8046 &active_submodule,
8047 super_scope_active,
8048 );
8049
8050 let comparison = compute_delta(&effective_baseline, &effective_current);
8051
8052 let file_rows: Vec<CompareFileDeltaRow> = comparison
8053 .file_deltas
8054 .iter()
8055 .map(|d| CompareFileDeltaRow {
8056 relative_path: d.relative_path.clone(),
8057 language: d.language.clone().unwrap_or_else(|| "—".into()),
8058 status: match d.status {
8059 FileChangeStatus::Added => "added".into(),
8060 FileChangeStatus::Removed => "removed".into(),
8061 FileChangeStatus::Modified => "modified".into(),
8062 FileChangeStatus::Unchanged => "unchanged".into(),
8063 },
8064 baseline_code: d.baseline_code,
8065 current_code: d.current_code,
8066 baseline_code_display: if d.status == FileChangeStatus::Added {
8067 "—".into()
8068 } else {
8069 d.baseline_code.to_string()
8070 },
8071 current_code_display: if d.status == FileChangeStatus::Removed {
8072 "—".into()
8073 } else {
8074 d.current_code.to_string()
8075 },
8076 code_delta_str: fmt_delta(d.code_delta),
8077 code_delta_class: delta_class(d.code_delta).into(),
8078 comment_delta_str: fmt_delta(d.comment_delta),
8079 comment_delta_class: delta_class(d.comment_delta).into(),
8080 total_delta_str: fmt_delta(d.total_delta),
8081 total_delta_class: delta_class(d.total_delta).into(),
8082 })
8083 .collect();
8084
8085 let project_path = baseline_entry
8086 .input_roots
8087 .first()
8088 .map(|s| sanitize_path_str(s))
8089 .unwrap_or_default();
8090 let lines_added = sum_added_code_lines(&comparison);
8091 let lines_removed = sum_removed_code_lines(&comparison);
8092 let churn = compute_churn_stats(
8093 comparison.summary.baseline_code,
8094 comparison.summary.current_code,
8095 lines_added,
8096 lines_removed,
8097 );
8098 let s = &comparison.summary;
8099 let template = CompareTemplate {
8100 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8101 version: env!("CARGO_PKG_VERSION"),
8102 project_label: baseline_entry.project_label.clone(),
8103 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8104 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8105 baseline_run_id: baseline_entry.run_id.clone(),
8106 current_run_id: current_entry.run_id.clone(),
8107 baseline_run_id_short: baseline_entry
8108 .run_id
8109 .split('-')
8110 .next_back()
8111 .unwrap_or(&baseline_entry.run_id)
8112 .chars()
8113 .take(7)
8114 .collect(),
8115 current_run_id_short: current_entry
8116 .run_id
8117 .split('-')
8118 .next_back()
8119 .unwrap_or(¤t_entry.run_id)
8120 .chars()
8121 .take(7)
8122 .collect(),
8123 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8124 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8125 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8126 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8127 project_path: project_path.clone(),
8128 baseline_code: s.baseline_code,
8129 current_code: s.current_code,
8130 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8131 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8132 baseline_files: s.baseline_files,
8133 current_files: s.current_files,
8134 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8135 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8136 baseline_comments: s.baseline_comments,
8137 current_comments: s.current_comments,
8138 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8139 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8140 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8141 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8142 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8143 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8144 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8145 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8146 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8147 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8148 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8149 code_lines_added: lines_added,
8150 code_lines_removed: lines_removed,
8151 new_scope: churn.new_scope,
8152 churn_rate_str: churn.churn_rate_str,
8153 churn_rate_class: churn.churn_rate_class,
8154 scope_flag: churn.scope_flag,
8155 files_added: comparison.files_added,
8156 files_removed: comparison.files_removed,
8157 files_modified: comparison.files_modified,
8158 files_unchanged: comparison.files_unchanged,
8159 file_rows,
8160 baseline_git_author: baseline_entry.git_author.clone(),
8161 current_git_author: current_entry.git_author.clone(),
8162 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8163 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8164 baseline_git_tags: baseline_entry.git_tags.clone(),
8165 current_git_tags: current_entry.git_tags.clone(),
8166 baseline_git_commit_date: baseline_entry
8167 .git_commit_date
8168 .as_deref()
8169 .and_then(fmt_git_date),
8170 current_git_commit_date: current_entry
8171 .git_commit_date
8172 .as_deref()
8173 .and_then(fmt_git_date),
8174 project_name: project_path
8175 .rsplit(['/', '\\'])
8176 .find(|s| !s.is_empty())
8177 .unwrap_or(&project_path)
8178 .to_string(),
8179 submodule_options,
8180 has_any_submodule_data,
8181 active_submodule,
8182 super_scope_active,
8183 toast_assets: sloc_toast_assets(&csp_nonce),
8184 csp_nonce,
8185 coverage_delta_card: build_coverage_delta_card(s),
8186 baseline_test_count: effective_baseline.summary_totals.test_count,
8187 current_test_count: effective_current.summary_totals.test_count,
8188 baseline_coverage_pct: s.baseline_coverage_line_pct,
8189 current_coverage_pct: s.current_coverage_line_pct,
8190 };
8191
8192 Html(
8193 template
8194 .render()
8195 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8196 )
8197 .into_response()
8198}
8199
8200fn format_number(n: u64) -> String {
8208 let s = n.to_string();
8209 let mut out = String::with_capacity(s.len() + s.len() / 3);
8210 let len = s.len();
8211 for (i, c) in s.chars().enumerate() {
8212 if i > 0 && (len - i).is_multiple_of(3) {
8213 out.push(',');
8214 }
8215 out.push(c);
8216 }
8217 out
8218}
8219
8220const fn badge_char_width(c: char) -> f64 {
8221 match c {
8222 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8223 'm' | 'w' => 9.0,
8224 ' ' => 4.0,
8225 _ => 6.5,
8226 }
8227}
8228
8229#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8230fn badge_text_px(text: &str) -> u32 {
8231 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8232}
8233
8234fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8235 let lw = badge_text_px(label) + 20;
8236 let rw = badge_text_px(value) + 20;
8237 let total = lw + rw;
8238 let lx = lw / 2;
8239 let rx = lw + rw / 2;
8240 let le = escape_html(label);
8241 let ve = escape_html(value);
8242 let ce = escape_html(color);
8243 format!(
8244 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8245 <rect width="{total}" height="20" fill="#555"/>
8246 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8247 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8248 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8249 <text x="{lx}" y="13">{le}</text>
8250 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8251 <text x="{rx}" y="13">{ve}</text>
8252 </g>
8253</svg>"##
8254 )
8255}
8256
8257#[derive(Deserialize)]
8258struct BadgeQuery {
8259 label: Option<String>,
8260 color: Option<String>,
8261}
8262
8263async fn badge_handler(
8264 State(state): State<AppState>,
8265 AxumPath(metric): AxumPath<String>,
8266 Query(query): Query<BadgeQuery>,
8267) -> Response {
8268 let entry = {
8269 let reg = state.registry.lock().await;
8270 reg.entries.first().cloned()
8271 };
8272
8273 let Some(entry) = entry else {
8274 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8275 return (
8276 [
8277 (header::CONTENT_TYPE, "image/svg+xml"),
8278 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8279 ],
8280 svg,
8281 )
8282 .into_response();
8283 };
8284
8285 let (default_label, value, default_color) = match metric.as_str() {
8286 "code-lines" => (
8287 "code lines",
8288 format_number(entry.summary.code_lines),
8289 "#4a78ee",
8290 ),
8291 "files" => (
8292 "files analyzed",
8293 format_number(entry.summary.files_analyzed),
8294 "#4a9862",
8295 ),
8296 "comment-lines" => (
8297 "comment lines",
8298 format_number(entry.summary.comment_lines),
8299 "#b35428",
8300 ),
8301 "blank-lines" => (
8302 "blank lines",
8303 format_number(entry.summary.blank_lines),
8304 "#7a5db0",
8305 ),
8306 _ => return StatusCode::NOT_FOUND.into_response(),
8307 };
8308
8309 let label = query.label.as_deref().unwrap_or(default_label);
8310 let color = query.color.as_deref().unwrap_or(default_color);
8311 let svg = render_badge_svg(label, &value, color);
8312
8313 (
8314 [
8315 (header::CONTENT_TYPE, "image/svg+xml"),
8316 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8317 ],
8318 svg,
8319 )
8320 .into_response()
8321}
8322
8323#[derive(Serialize)]
8331struct ApiCoverageBlock {
8332 lines_found: u64,
8333 lines_hit: u64,
8334 line_pct: f64,
8335 functions_found: u64,
8336 functions_hit: u64,
8337 function_pct: f64,
8338 branches_found: u64,
8339 branches_hit: u64,
8340 branch_pct: f64,
8341}
8342
8343#[derive(Serialize)]
8344struct ApiMetricsResponse {
8345 run_id: String,
8346 timestamp: String,
8347 project: String,
8348 summary: ApiSummaryPayload,
8349 languages: Vec<ApiLanguageRow>,
8350 #[serde(skip_serializing_if = "Option::is_none")]
8351 coverage: Option<ApiCoverageBlock>,
8352}
8353
8354#[derive(Serialize)]
8355struct ApiSummaryPayload {
8356 files_analyzed: u64,
8357 files_skipped: u64,
8358 code_lines: u64,
8359 comment_lines: u64,
8360 blank_lines: u64,
8361 total_physical_lines: u64,
8362 functions: u64,
8363 classes: u64,
8364 variables: u64,
8365 imports: u64,
8366}
8367
8368#[derive(Serialize)]
8369struct ApiLanguageRow {
8370 name: String,
8371 files: u64,
8372 code_lines: u64,
8373 comment_lines: u64,
8374 blank_lines: u64,
8375 functions: u64,
8376 classes: u64,
8377 variables: u64,
8378 imports: u64,
8379}
8380
8381async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8382 let entry = {
8383 let reg = state.registry.lock().await;
8384 reg.entries.first().cloned()
8385 };
8386 entry.map_or_else(
8387 || error::not_found("no scans recorded yet"),
8388 |e| build_metrics_response(&e),
8389 )
8390}
8391
8392async fn api_metrics_run_handler(
8393 State(state): State<AppState>,
8394 AxumPath(run_id): AxumPath<String>,
8395) -> Response {
8396 let entry = {
8397 let reg = state.registry.lock().await;
8398 reg.find_by_run_id(&run_id).cloned()
8399 };
8400 entry.map_or_else(
8401 || error::not_found("run not found"),
8402 |e| build_metrics_response(&e),
8403 )
8404}
8405
8406fn build_metrics_response(entry: &RegistryEntry) -> Response {
8407 let languages: Vec<ApiLanguageRow> = entry
8408 .json_path
8409 .as_ref()
8410 .and_then(|p| read_json(p).ok())
8411 .map(|run| {
8412 run.totals_by_language
8413 .iter()
8414 .map(|l| ApiLanguageRow {
8415 name: l.language.display_name().to_string(),
8416 files: l.files,
8417 code_lines: l.code_lines,
8418 comment_lines: l.comment_lines,
8419 blank_lines: l.blank_lines,
8420 functions: l.functions,
8421 classes: l.classes,
8422 variables: l.variables,
8423 imports: l.imports,
8424 })
8425 .collect()
8426 })
8427 .unwrap_or_default();
8428
8429 let s = &entry.summary;
8430 let coverage = if s.coverage_lines_found > 0 {
8431 let pct = |hit: u64, found: u64| -> f64 {
8432 if found == 0 {
8433 0.0
8434 } else {
8435 #[allow(clippy::cast_precision_loss)]
8436 let v = (hit as f64 / found as f64) * 100.0;
8437 (v * 10.0).round() / 10.0
8438 }
8439 };
8440 Some(ApiCoverageBlock {
8441 lines_found: s.coverage_lines_found,
8442 lines_hit: s.coverage_lines_hit,
8443 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8444 functions_found: s.coverage_functions_found,
8445 functions_hit: s.coverage_functions_hit,
8446 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8447 branches_found: s.coverage_branches_found,
8448 branches_hit: s.coverage_branches_hit,
8449 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8450 })
8451 } else {
8452 None
8453 };
8454 Json(ApiMetricsResponse {
8455 run_id: entry.run_id.clone(),
8456 timestamp: entry.timestamp_utc.to_rfc3339(),
8457 project: entry.project_label.clone(),
8458 summary: ApiSummaryPayload {
8459 files_analyzed: s.files_analyzed,
8460 files_skipped: s.files_skipped,
8461 code_lines: s.code_lines,
8462 comment_lines: s.comment_lines,
8463 blank_lines: s.blank_lines,
8464 total_physical_lines: s.total_physical_lines,
8465 functions: s.functions,
8466 classes: s.classes,
8467 variables: s.variables,
8468 imports: s.imports,
8469 },
8470 languages,
8471 coverage,
8472 })
8473 .into_response()
8474}
8475
8476#[derive(Deserialize)]
8483struct ProjectHistoryQuery {
8484 path: Option<String>,
8485}
8486
8487#[derive(Serialize)]
8488struct ProjectHistoryResponse {
8489 scan_count: usize,
8490 last_scan_id: Option<String>,
8491 last_scan_timestamp: Option<String>,
8492 last_scan_code_lines: Option<u64>,
8493 last_git_branch: Option<String>,
8494 last_git_commit: Option<String>,
8495}
8496
8497fn entry_matches_project(
8500 entry: &RegistryEntry,
8501 root_str: &str,
8502 upload_root: &str,
8503 upload_name_suffix: Option<&str>,
8504) -> bool {
8505 if entry.input_roots.iter().any(|r| r == root_str) {
8506 return true;
8507 }
8508 if let Some(suffix) = upload_name_suffix {
8509 return entry
8510 .input_roots
8511 .iter()
8512 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8513 }
8514 false
8515}
8516
8517async fn project_history_handler(
8518 State(state): State<AppState>,
8519 Query(query): Query<ProjectHistoryQuery>,
8520) -> Response {
8521 let path = query.path.unwrap_or_default();
8522 let resolved = resolve_input_path(&path);
8523 let root_str = resolved.to_string_lossy().replace('\\', "/");
8524
8525 let upload_root = std::env::temp_dir()
8530 .join("oxide-sloc-uploads")
8531 .to_string_lossy()
8532 .replace('\\', "/");
8533 let upload_name_suffix: Option<String> =
8534 if state.server_mode && root_str.starts_with(&upload_root) {
8535 resolved
8536 .file_name()
8537 .and_then(|n| n.to_str())
8538 .map(|name| format!("/{name}"))
8539 } else {
8540 None
8541 };
8542 let suffix_ref = upload_name_suffix.as_deref();
8543
8544 let entries: Vec<_> = {
8545 let reg = state.registry.lock().await;
8546 reg.entries
8547 .iter()
8548 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8549 .cloned()
8550 .collect()
8551 };
8552 let scan_count = entries.len();
8553 let last = entries.first();
8554 let last_scan_id = last.map(|e| e.run_id.clone());
8555 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8556 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8557 let last_git_branch = last.and_then(|e| e.git_branch.clone());
8558 let last_git_commit = last.and_then(|e| e.git_commit.clone());
8559
8560 Json(ProjectHistoryResponse {
8561 scan_count,
8562 last_scan_id,
8563 last_scan_timestamp,
8564 last_scan_code_lines,
8565 last_git_branch,
8566 last_git_commit,
8567 })
8568 .into_response()
8569}
8570
8571#[derive(Deserialize)]
8578struct MetricsHistoryQuery {
8579 root: Option<String>,
8580 limit: Option<usize>,
8581 submodule: Option<String>,
8584}
8585
8586#[derive(Serialize)]
8587struct MetricsSubmoduleLink {
8588 name: String,
8589 url: String,
8590}
8591
8592#[derive(Serialize)]
8593struct MetricsHistoryEntry {
8594 run_id: String,
8595 run_id_short: String,
8596 timestamp: String,
8597 commit: Option<String>,
8598 branch: Option<String>,
8599 tags: Vec<String>,
8600 nearest_tag: Option<String>,
8601 code_lines: u64,
8602 comment_lines: u64,
8603 blank_lines: u64,
8604 physical_lines: u64,
8605 files_analyzed: u64,
8606 files_skipped: u64,
8607 test_count: u64,
8608 project_label: String,
8609 html_url: Option<String>,
8610 has_pdf: bool,
8611 submodule_links: Vec<MetricsSubmoduleLink>,
8612 #[serde(skip_serializing_if = "Option::is_none")]
8614 coverage_line_pct: Option<f64>,
8615}
8616
8617fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8618 let mut links: Vec<MetricsSubmoduleLink> = vec![];
8619 let sub_dir = e
8620 .html_path
8621 .as_ref()
8622 .and_then(|p| p.parent())
8623 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8624 let Some(dir) = sub_dir else { return links };
8625 let Ok(rd) = std::fs::read_dir(dir) else {
8626 return links;
8627 };
8628 for entry_res in rd.flatten() {
8629 let fname = entry_res.file_name();
8630 let fname_str = fname.to_string_lossy();
8631 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8632 let stem = &fname_str[..fname_str.len() - 5];
8633 let display = stem[4..].replace('-', " ");
8634 links.push(MetricsSubmoduleLink {
8635 name: display,
8636 url: format!("/runs/{stem}/{}", e.run_id),
8637 });
8638 }
8639 }
8640 links.sort_by(|a, b| a.name.cmp(&b.name));
8641 links
8642}
8643
8644fn apply_submodule_filter(
8645 base: MetricsHistoryEntry,
8646 filter: &str,
8647 e: &sloc_core::history::RegistryEntry,
8648) -> Option<MetricsHistoryEntry> {
8649 let json_path = e.json_path.as_ref()?;
8650 let json_str = std::fs::read_to_string(json_path).ok()?;
8651 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8652 let sub = run
8653 .submodule_summaries
8654 .iter()
8655 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8656 let safe = sanitize_project_label(&sub.name);
8657 let artifact_key = format!("sub_{safe}");
8658 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8659 || base.html_url.clone(),
8660 |run_dir| {
8661 let sub_path = run_dir.join(format!("{artifact_key}.html"));
8662 if sub_path.exists() {
8663 Some(format!("/runs/{artifact_key}/{}", e.run_id))
8664 } else {
8665 base.html_url.clone()
8666 }
8667 },
8668 );
8669
8670 let sub_files: Vec<_> = run
8673 .per_file_records
8674 .iter()
8675 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8676 .collect();
8677 let test_count: u64 = sub_files
8678 .iter()
8679 .map(|r| r.raw_line_categories.test_count)
8680 .sum();
8681 #[allow(clippy::cast_precision_loss)]
8682 let coverage_line_pct: Option<f64> = {
8683 let found: u64 = sub_files
8684 .iter()
8685 .filter_map(|r| r.coverage.as_ref())
8686 .map(|c| u64::from(c.lines_found))
8687 .sum();
8688 let hit: u64 = sub_files
8689 .iter()
8690 .filter_map(|r| r.coverage.as_ref())
8691 .map(|c| u64::from(c.lines_hit))
8692 .sum();
8693 if found > 0 {
8694 let pct = (hit as f64 / found as f64) * 100.0;
8695 Some((pct * 10.0).round() / 10.0)
8696 } else {
8697 None
8698 }
8699 };
8700
8701 Some(MetricsHistoryEntry {
8702 code_lines: sub.code_lines,
8703 comment_lines: sub.comment_lines,
8704 blank_lines: sub.blank_lines,
8705 physical_lines: sub.total_physical_lines,
8706 files_analyzed: sub.files_analyzed,
8707 files_skipped: 0,
8708 test_count,
8709 html_url: sub_html_url,
8710 has_pdf: false,
8711 submodule_links: vec![],
8712 coverage_line_pct,
8713 ..base
8714 })
8715}
8716
8717#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
8719 State(state): State<AppState>,
8720 Query(query): Query<MetricsHistoryQuery>,
8721) -> Response {
8722 let limit = query.limit.unwrap_or(50).min(500);
8723 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8724
8725 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8726 let reg = state.registry.lock().await;
8727 reg.entries
8728 .iter()
8729 .filter(|e| {
8730 query.root.as_ref().is_none_or(|root| {
8731 let resolved = resolve_input_path(root);
8732 let root_str = resolved.to_string_lossy().replace('\\', "/");
8733 e.input_roots.iter().any(|r| r == &root_str)
8734 })
8735 })
8736 .take(limit)
8737 .cloned()
8738 .collect()
8739 };
8740
8741 let entries: Vec<MetricsHistoryEntry> = candidate_entries
8742 .into_iter()
8743 .filter_map(|e| {
8744 let tags = e
8745 .git_tags
8746 .as_deref()
8747 .map(|s| {
8748 s.split(',')
8749 .map(|t| t.trim().to_string())
8750 .filter(|t| !t.is_empty())
8751 .collect()
8752 })
8753 .unwrap_or_default();
8754 let html_url = e
8755 .html_path
8756 .as_ref()
8757 .filter(|p| p.exists())
8758 .map(|_| format!("/runs/html/{}", e.run_id));
8759 let nearest_tag = e.git_nearest_tag.clone();
8760 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8761 let run_id_short: String = e
8762 .run_id
8763 .split('-')
8764 .next_back()
8765 .unwrap_or(&e.run_id)
8766 .chars()
8767 .take(7)
8768 .collect();
8769 let submodule_links = build_entry_submodule_links(&e);
8770 #[allow(clippy::cast_precision_loss)]
8771 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8772 let pct = (e.summary.coverage_lines_hit as f64
8773 / e.summary.coverage_lines_found as f64)
8774 * 100.0;
8775 Some((pct * 10.0).round() / 10.0)
8776 } else {
8777 None
8778 };
8779 let base = MetricsHistoryEntry {
8780 run_id: e.run_id.clone(),
8781 run_id_short,
8782 timestamp: e.timestamp_utc.to_rfc3339(),
8783 commit: e.git_commit.clone(),
8784 branch: e.git_branch.clone(),
8785 tags,
8786 nearest_tag,
8787 code_lines: e.summary.code_lines,
8788 comment_lines: e.summary.comment_lines,
8789 blank_lines: e.summary.blank_lines,
8790 physical_lines: e.summary.total_physical_lines,
8791 files_analyzed: e.summary.files_analyzed,
8792 files_skipped: e.summary.files_skipped,
8793 test_count: e.summary.test_count,
8794 project_label: e.project_label.clone(),
8795 html_url,
8796 has_pdf,
8797 submodule_links,
8798 coverage_line_pct,
8799 };
8800 if let Some(ref filter) = submodule_filter {
8801 apply_submodule_filter(base, filter, &e)
8802 } else {
8803 Some(base)
8804 }
8805 })
8806 .collect();
8807
8808 Json(entries).into_response()
8809}
8810
8811#[derive(Serialize)]
8813struct ChurnEntry {
8814 run_id: String,
8815 added: i64,
8816 removed: i64,
8817 modified: i64,
8818 unmodified: i64,
8819}
8820
8821async fn api_metrics_churn_handler(
8826 State(state): State<AppState>,
8827 Query(query): Query<MetricsHistoryQuery>,
8828) -> Response {
8829 let limit = query.limit.unwrap_or(200).min(500);
8830 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8831 let reg = state.registry.lock().await;
8832 reg.entries
8833 .iter()
8834 .filter(|e| {
8835 query.root.as_ref().is_none_or(|root| {
8836 let resolved = resolve_input_path(root);
8837 let root_str = resolved.to_string_lossy().replace('\\', "/");
8838 e.input_roots.iter().any(|r| r == &root_str)
8839 })
8840 })
8841 .take(limit)
8842 .cloned()
8843 .collect()
8844 };
8845 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8846 std::collections::HashMap::new();
8847 for e in candidate_entries {
8848 by_project
8849 .entry(e.project_label.clone())
8850 .or_default()
8851 .push(e);
8852 }
8853 let mut out: Vec<ChurnEntry> = Vec::new();
8854 for (_proj, mut entries) in by_project {
8855 entries.sort_by_key(|e| e.timestamp_utc);
8856 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
8857 for e in &entries {
8858 let curr = e
8859 .json_path
8860 .as_ref()
8861 .and_then(|path| sloc_core::read_json(path).ok());
8862 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
8863 let cmp = sloc_core::compute_delta(prev, cur);
8864 out.push(ChurnEntry {
8865 run_id: e.run_id.clone(),
8866 added: sum_added_code_lines(&cmp),
8867 removed: sum_removed_code_lines(&cmp),
8868 modified: sum_modified_code_lines(&cmp),
8869 unmodified: sum_unmodified_code_lines(&cmp),
8870 });
8871 } else {
8872 out.push(ChurnEntry {
8873 run_id: e.run_id.clone(),
8874 added: 0,
8875 removed: 0,
8876 modified: 0,
8877 unmodified: 0,
8878 });
8879 }
8880 if curr.is_some() {
8881 prev_run = curr;
8882 }
8883 }
8884 }
8885 Json(out).into_response()
8886}
8887
8888#[derive(Deserialize)]
8892struct MetricsSubmodulesQuery {
8893 root: Option<String>,
8894}
8895
8896#[derive(Serialize)]
8897struct SubmoduleEntry {
8898 name: String,
8899 relative_path: String,
8900}
8901
8902async fn api_metrics_submodules_handler(
8903 State(state): State<AppState>,
8904 Query(query): Query<MetricsSubmodulesQuery>,
8905) -> Response {
8906 let json_paths: Vec<std::path::PathBuf> = {
8907 let reg = state.registry.lock().await;
8908 reg.entries
8909 .iter()
8910 .filter(|e| {
8911 query.root.as_ref().is_none_or(|root| {
8912 let resolved = resolve_input_path(root);
8913 let root_str = resolved.to_string_lossy().replace('\\', "/");
8914 e.input_roots.iter().any(|r| r == &root_str)
8915 })
8916 })
8917 .filter_map(|e| e.json_path.clone())
8918 .collect()
8919 };
8920
8921 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8922 let mut result: Vec<SubmoduleEntry> = Vec::new();
8923
8924 for path in &json_paths {
8925 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8926 continue;
8927 };
8928 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8929 continue;
8930 };
8931 for sub in &run.submodule_summaries {
8932 if seen.insert(sub.name.clone()) {
8933 result.push(SubmoduleEntry {
8934 name: sub.name.clone(),
8935 relative_path: sub.relative_path.clone(),
8936 });
8937 }
8938 }
8939 }
8940
8941 result.sort_by(|a, b| a.name.cmp(&b.name));
8942 Json(result).into_response()
8943}
8944
8945#[derive(Deserialize)]
8954struct IngestQuery {
8955 label: Option<String>,
8956}
8957
8958#[derive(Serialize)]
8959struct IngestResponse {
8960 run_id: String,
8961 view_url: String,
8962}
8963
8964async fn api_ingest_handler(
8965 State(state): State<AppState>,
8966 Query(q): Query<IngestQuery>,
8967 Json(run): Json<sloc_core::AnalysisRun>,
8968) -> Response {
8969 let label = q.label.unwrap_or_else(|| {
8970 run.input_roots
8971 .first()
8972 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8973 });
8974
8975 let label_for_task = label.clone();
8976 let result = tokio::task::spawn_blocking(move || {
8977 let html = render_html(&run)?;
8978 let run_id = run.tool.run_id.clone();
8979 let run_id_safe = run_id.len() <= 128
8980 && !run_id.is_empty()
8981 && run_id
8982 .chars()
8983 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
8984 if !run_id_safe {
8985 anyhow::bail!(
8986 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
8987 );
8988 }
8989 let project_label = sanitize_project_label(&label_for_task);
8990 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8991 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
8992 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
8993 _ => project_label,
8994 };
8995 let (artifacts, _pending_pdf) = persist_run_artifacts(
8996 &run,
8997 &html,
8998 &output_dir,
8999 &label_for_task,
9000 &file_stem,
9001 RunResultContext::default(),
9002 )?;
9003 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9004 })
9005 .await;
9006
9007 match result {
9008 Ok(Ok((run_id, artifacts, run))) => {
9009 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9010 (
9011 StatusCode::CREATED,
9012 Json(IngestResponse {
9013 view_url: format!("/view-reports?run_id={run_id}"),
9014 run_id,
9015 }),
9016 )
9017 .into_response()
9018 }
9019 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9020 Err(e) => error::internal(&format!("{e}")),
9021 }
9022}
9023
9024fn html_escape(s: &str) -> String {
9028 s.replace('&', "&")
9029 .replace('<', "<")
9030 .replace('>', ">")
9031 .replace('"', """)
9032}
9033
9034#[allow(clippy::cast_precision_loss)]
9035fn fmt_num(n: i64) -> String {
9036 let a = n.unsigned_abs();
9037 if a >= 1_000_000 {
9038 let v = n as f64 / 1_000_000.0;
9039 let s = format!("{v:.1}");
9040 format!("{}M", s.trim_end_matches(".0"))
9041 } else if a >= 10_000 {
9042 let v = n as f64 / 1_000.0;
9043 let s = format!("{v:.1}");
9044 format!("{}K", s.trim_end_matches(".0"))
9045 } else {
9046 let sign = if n < 0 { "-" } else { "" };
9047 if a < 1_000 {
9048 return format!("{sign}{a}");
9049 }
9050 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9051 }
9052}
9053
9054fn fmt_comma(n: i64) -> String {
9055 let sign = if n < 0 { "-" } else { "" };
9056 let a = n.unsigned_abs();
9057 if a < 1_000 {
9058 return format!("{sign}{a}");
9059 }
9060 let s = a.to_string();
9061 let bytes = s.as_bytes();
9062 let len = bytes.len();
9063 let mut out = String::with_capacity(len + len / 3);
9064 for (i, &b) in bytes.iter().enumerate() {
9065 if i > 0 && (len - i).is_multiple_of(3) {
9066 out.push(',');
9067 }
9068 out.push(b as char);
9069 }
9070 format!("{sign}{out}")
9071}
9072
9073fn group_thousands(s: &str) -> String {
9080 let (sign, rest) = match s.as_bytes().first() {
9081 Some(b'-') => ("-", &s[1..]),
9082 Some(b'+') => ("+", &s[1..]),
9083 _ => ("", s),
9084 };
9085 let (int_part, frac_part) = match rest.split_once('.') {
9086 Some((i, f)) => (i, Some(f)),
9087 None => (rest, None),
9088 };
9089 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9090 return s.to_string();
9091 }
9092 let bytes = int_part.as_bytes();
9093 let len = bytes.len();
9094 let mut grouped = String::with_capacity(len + len / 3);
9095 for (i, &b) in bytes.iter().enumerate() {
9096 if i > 0 && (len - i).is_multiple_of(3) {
9097 grouped.push(',');
9098 }
9099 grouped.push(b as char);
9100 }
9101 frac_part.map_or_else(
9102 || format!("{sign}{grouped}"),
9103 |f| format!("{sign}{grouped}.{f}"),
9104 )
9105}
9106
9107mod filters {
9109 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9112 use askama::{Result, Values};
9113
9114 #[askama::filter_fn]
9119 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9120 Ok(super::group_thousands(&value.to_string()))
9121 }
9122}
9123
9124#[derive(Deserialize, Default)]
9125struct MultiCompareQuery {
9126 runs: Option<String>,
9127 scope: Option<String>,
9129 sub: Option<String>,
9131}
9132
9133#[allow(clippy::too_many_lines)]
9134async fn multi_compare_handler(
9135 State(state): State<AppState>,
9136 Query(params): Query<MultiCompareQuery>,
9137 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9138) -> impl IntoResponse {
9139 let run_ids: Vec<String> = params
9140 .runs
9141 .as_deref()
9142 .unwrap_or("")
9143 .split(',')
9144 .map(|s| s.trim().to_string())
9145 .filter(|s| !s.is_empty())
9146 .collect();
9147
9148 if run_ids.len() < 2 {
9149 return Html(
9150 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9151 <a href=\"/compare-scans\">Go back</a></p>",
9152 )
9153 .into_response();
9154 }
9155 if run_ids.len() > 20 {
9156 return Html(
9157 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9158 at once. <a href=\"/compare-scans\">Go back</a></p>",
9159 )
9160 .into_response();
9161 }
9162
9163 let entries: Vec<Option<RegistryEntry>> = {
9165 let reg = state.registry.lock().await;
9166 run_ids
9167 .iter()
9168 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9169 .collect()
9170 };
9171
9172 for (i, entry) in entries.iter().enumerate() {
9173 if entry.is_none() {
9174 let html = format!(
9175 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9176 found. <a href=\"/compare-scans\">Go back</a></p>",
9177 run_ids[i]
9178 );
9179 return Html(html).into_response();
9180 }
9181 }
9182
9183 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9184
9185 for entry in &entries {
9186 if entry.json_path.is_none() {
9187 let html = format!(
9188 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9189 JSON data — re-run the analysis to enable comparison. \
9190 <a href=\"/compare-scans\">Go back</a></p>",
9191 &entry.run_id
9192 );
9193 return Html(html).into_response();
9194 }
9195 }
9196
9197 entries.sort_by_key(|e| e.timestamp_utc);
9199
9200 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9202 for entry in &entries {
9203 let path = entry.json_path.as_ref().unwrap();
9204 match read_json(path) {
9205 Ok(r) => runs.push(r),
9206 Err(e) => {
9207 let html = format!(
9208 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9209 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9210 &entry.run_id
9211 );
9212 return Html(html).into_response();
9213 }
9214 }
9215 }
9216
9217 let all_sub_names: Vec<String> = {
9219 let mut set = std::collections::BTreeSet::new();
9220 for r in &runs {
9221 for s in &r.submodule_summaries {
9222 set.insert(s.name.clone());
9223 }
9224 }
9225 set.into_iter().collect()
9226 };
9227 let has_submodule_data = !all_sub_names.is_empty();
9228 let active_submodule = params.sub.clone();
9229 let super_scope_active = params.scope.as_deref() == Some("super");
9230
9231 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9233
9234 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9235 let project_label = entries
9236 .first()
9237 .map_or("", |e| e.project_label.as_str())
9238 .to_string();
9239 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9240 let multi = compute_multi_delta(&run_refs);
9241 let html = multi_compare_page(
9242 &multi,
9243 &project_label,
9244 env!("CARGO_PKG_VERSION"),
9245 &csp_nonce,
9246 has_submodule_data,
9247 &all_sub_names,
9248 &runs_csv,
9249 super_scope_active,
9250 active_submodule.as_deref(),
9251 &entries,
9252 );
9253 (
9256 [(axum::http::header::CACHE_CONTROL, "no-store")],
9257 Html(html),
9258 )
9259 .into_response()
9260}
9261
9262const fn multi_delta_class(n: i64) -> &'static str {
9263 match n {
9264 1.. => "pos",
9265 ..=-1 => "neg",
9266 0 => "zero",
9267 }
9268}
9269
9270fn multi_fmt_delta(n: i64) -> String {
9271 if n > 0 {
9272 format!("+{n}")
9273 } else {
9274 format!("{n}")
9275 }
9276}
9277
9278fn js_escape(s: &str) -> String {
9280 use std::fmt::Write as _;
9281 let mut out = String::with_capacity(s.len() + 2);
9282 for c in s.chars() {
9283 match c {
9284 '"' => out.push_str("\\\""),
9285 '\\' => out.push_str("\\\\"),
9286 '\n' => out.push_str("\\n"),
9287 '\r' => out.push_str("\\r"),
9288 '\t' => out.push_str("\\t"),
9289 c if (c as u32) < 0x20 => {
9290 let _ = write!(out, "\\u{:04x}", c as u32);
9291 }
9292 c => out.push(c),
9293 }
9294 }
9295 out
9296}
9297
9298fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9300 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9301 return (
9302 "—".to_string(),
9303 "<span class=\"mc-row-val\">—</span>".to_string(),
9304 );
9305 };
9306 let cd = entry
9307 .git_commit_date
9308 .as_deref()
9309 .and_then(fmt_git_date)
9310 .unwrap_or_else(|| "—".to_string());
9311 let au = entry.git_author.as_deref().map_or_else(
9312 || "<span class=\"mc-row-val\">—</span>".to_string(),
9313 |a| {
9314 format!(
9315 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9316 <span class=\"cmp-author-handle\"></span></span>",
9317 html_escape(a)
9318 )
9319 },
9320 );
9321 (cd, au)
9322}
9323
9324fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9326 active_sub.map_or_else(
9327 || {
9328 if super_scope_active {
9329 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9330 } else {
9331 "<span class=\"mc-scope-tag mc-scope-full\">\
9332 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9333 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9334 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9335 <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>\
9336 </svg> Full scan</span>"
9337 .to_string()
9338 }
9339 },
9340 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9341 )
9342}
9343
9344fn build_mc_scan_strip(
9346 multi: &MultiScanComparison,
9347 entries: &[RegistryEntry],
9348 n: usize,
9349 is_many: bool,
9350 active_sub: Option<&str>,
9351 super_scope_active: bool,
9352 project_label: &str,
9353) -> String {
9354 use std::fmt::Write as _;
9355 let mut scan_strip = String::new();
9356 for (i, pt) in multi.points.iter().enumerate() {
9357 let ts_ms = pt.timestamp.timestamp_millis();
9358 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9359 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9360 let branch = pt.git_branch.as_deref().unwrap_or("");
9361 let report_link = format!("/runs/html/{}", pt.run_id);
9362 let branch_html = if branch.is_empty() {
9363 "<span class=\"mc-row-val\">—</span>".to_string()
9364 } else {
9365 format!(
9366 "<span class=\"mc-card-branch\">{}</span>",
9367 html_escape(branch)
9368 )
9369 };
9370 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9371 let tags_html = pt
9372 .git_tags
9373 .as_deref()
9374 .filter(|t| !t.is_empty())
9375 .map(|t| {
9376 let chips = t
9377 .split(',')
9378 .filter(|s| !s.is_empty())
9379 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9380 .collect::<Vec<_>>()
9381 .join(" ");
9382 format!(
9383 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9384 <span class=\"mc-row-val\">{chips}</span></div>"
9385 )
9386 })
9387 .unwrap_or_default();
9388 let nearest = pt
9389 .git_nearest_tag
9390 .as_deref()
9391 .map(|t| format!("near {}", html_escape(t)))
9392 .unwrap_or_default();
9393 let arrow = if i < n - 1 && !is_many {
9394 "<div class='mc-arrow'>→</div>"
9395 } else {
9396 ""
9397 };
9398 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9399 let nearest_html = if nearest.is_empty() {
9400 String::new()
9401 } else {
9402 format!(
9403 "<span class=\"mc-card-nearest-wrap\">\
9404 <span class=\"mc-card-nearest\">{nearest}</span>\
9405 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9406 </span>"
9407 )
9408 };
9409 write!(
9410 scan_strip,
9411 r#"<div class="mc-card">
9412 <div class="mc-card-header">
9413 <div class="mc-card-num">Scan {num}</div>
9414 <div class="mc-card-project-col">
9415 <div class="mc-card-project">{project_label}</div>
9416 {scope_badge}
9417 </div>
9418 </div>
9419 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9420 <div class="mc-card-rows">
9421 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9422 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9423 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9424 <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>
9425 {tags_html}
9426 </div>
9427 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9428 </div>{arrow}"#,
9429 num = i + 1,
9430 commit = html_escape(commit),
9431 commit_date = commit_date_html,
9432 ts_ms = ts_ms,
9433 code = fmt_num(pt.code_lines),
9434 scope_badge = scope_badge,
9435 nearest_html = nearest_html,
9436 )
9437 .unwrap();
9438 }
9439 scan_strip
9440}
9441
9442#[allow(clippy::too_many_lines)]
9444fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9445 use std::fmt::Write as _;
9446 struct MetricRow<'a> {
9447 label: &'a str,
9448 values: Vec<i64>,
9449 seq_deltas: Vec<i64>,
9450 net_delta: i64,
9451 }
9452 let rows: Vec<MetricRow<'_>> = vec![
9453 MetricRow {
9454 label: "Code Lines",
9455 values: multi.points.iter().map(|p| p.code_lines).collect(),
9456 seq_deltas: multi
9457 .sequential_deltas
9458 .iter()
9459 .map(|d| d.summary.code_lines_delta)
9460 .collect(),
9461 net_delta: multi.total_delta.code_lines_delta,
9462 },
9463 MetricRow {
9464 label: "Files Analyzed",
9465 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9466 seq_deltas: multi
9467 .sequential_deltas
9468 .iter()
9469 .map(|d| d.summary.files_analyzed_delta)
9470 .collect(),
9471 net_delta: multi.total_delta.files_analyzed_delta,
9472 },
9473 MetricRow {
9474 label: "Comment Lines",
9475 values: multi.points.iter().map(|p| p.comment_lines).collect(),
9476 seq_deltas: multi
9477 .sequential_deltas
9478 .iter()
9479 .map(|d| d.summary.comment_lines_delta)
9480 .collect(),
9481 net_delta: multi.total_delta.comment_lines_delta,
9482 },
9483 MetricRow {
9484 label: "Blank Lines",
9485 values: multi.points.iter().map(|p| p.blank_lines).collect(),
9486 seq_deltas: multi
9487 .sequential_deltas
9488 .iter()
9489 .map(|d| d.summary.blank_lines_delta)
9490 .collect(),
9491 net_delta: multi.total_delta.blank_lines_delta,
9492 },
9493 MetricRow {
9494 label: "Tests",
9495 values: multi.points.iter().map(|p| p.test_count).collect(),
9496 seq_deltas: multi
9497 .points
9498 .windows(2)
9499 .map(|pts| pts[1].test_count - pts[0].test_count)
9500 .collect(),
9501 net_delta: multi.points.last().map_or(0, |l| l.test_count)
9502 - multi.points.first().map_or(0, |f| f.test_count),
9503 },
9504 ];
9505 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9506 for i in 0..n {
9507 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9508 if i < n - 1 {
9509 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
9510 }
9511 }
9512 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
9513 let mut metrics_tbody = String::new();
9514 for row in &rows {
9515 metrics_tbody.push_str("<tr>");
9516 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9517 for i in 0..n {
9518 write!(
9519 metrics_tbody,
9520 "<td class='mc-val-col'>{}</td>",
9521 fmt_comma(row.values[i])
9522 )
9523 .unwrap();
9524 if i < n - 1 {
9525 let d = row.seq_deltas[i];
9526 write!(
9527 metrics_tbody,
9528 "<td class='mc-delta-col {cls}'>{val}</td>",
9529 cls = multi_delta_class(d),
9530 val = multi_fmt_delta(d)
9531 )
9532 .unwrap();
9533 }
9534 }
9535 let nd = row.net_delta;
9536 write!(
9537 metrics_tbody,
9538 "<td class='mc-net-col {cls}'>{val}</td>",
9539 cls = multi_delta_class(nd),
9540 val = multi_fmt_delta(nd)
9541 )
9542 .unwrap();
9543 metrics_tbody.push_str("</tr>");
9544 }
9545 (metrics_thead, metrics_tbody)
9546}
9547
9548fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9550 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9551 for (i, pt) in multi.points.iter().enumerate() {
9552 let commit = pt.git_commit.as_deref().unwrap_or("");
9553 let branch = pt.git_branch.as_deref().unwrap_or("");
9554 let tags = pt.git_tags.as_deref().unwrap_or("");
9555 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9556 let scanned_ms = pt.timestamp.timestamp_millis();
9557 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9558 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9559 let commit_date = entry
9560 .and_then(|e| e.git_commit_date.as_deref())
9561 .and_then(fmt_git_date)
9562 .unwrap_or_default();
9563 let author = entry
9564 .and_then(|e| e.git_author.as_deref())
9565 .unwrap_or("")
9566 .to_string();
9567 let cov = pt
9568 .coverage_line_pct
9569 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9570 parts.push(format!(
9571 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}}}"#,
9572 run_id = js_escape(&pt.run_id),
9573 commit = js_escape(commit),
9574 branch = js_escape(branch),
9575 tags = js_escape(tags),
9576 nearest = js_escape(nearest),
9577 commit_date = js_escape(&commit_date),
9578 author = js_escape(&author),
9579 scanned = js_escape(&scanned),
9580 code = pt.code_lines,
9581 comments = pt.comment_lines,
9582 blank = pt.blank_lines,
9583 files = pt.files_analyzed,
9584 tests = pt.test_count,
9585 ));
9586 }
9587 format!("[{}]", parts.join(","))
9588}
9589
9590fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9592 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9593 for row in &multi.file_matrix {
9594 let lang = row.language.as_deref().unwrap_or("");
9595 let codes: Vec<String> = row
9596 .code_per_scan
9597 .iter()
9598 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9599 .collect();
9600 let deltas: Vec<String> = row
9601 .code_delta_per_scan
9602 .iter()
9603 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9604 .collect();
9605 parts.push(format!(
9606 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9607 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9608 status = row.overall_status,
9609 codes = codes.join(","),
9610 deltas = deltas.join(","),
9611 total = row.total_code_delta,
9612 ));
9613 }
9614 format!("[{}]", parts.join(","))
9615}
9616
9617fn build_mc_file_col_headers(n: usize) -> String {
9619 use std::fmt::Write as _;
9620 let mut out = String::new();
9621 for i in 0..n {
9622 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9623 if i < n - 1 {
9624 write!(
9625 out,
9626 "<th class='file-delta-col'>Δ→{}</th>",
9627 i + 2
9628 )
9629 .unwrap();
9630 }
9631 }
9632 out
9633}
9634
9635fn build_mc_scope_bar(
9637 has_submodule_data: bool,
9638 sub_names: &[String],
9639 runs_csv: &str,
9640 active_sub: Option<&str>,
9641 super_scope_active: bool,
9642) -> String {
9643 use std::fmt::Write as _;
9644 if !has_submodule_data {
9645 return String::new();
9646 }
9647 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9648 let full_active = active_sub.is_none() && !super_scope_active;
9649 let mut bar = format!(
9650 r#"<div class="submod-scope-bar">
9651 <span class="submod-scope-label">
9652 <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>
9653 Scope:
9654 </span>
9655 <div class="submod-scope-divider"></div>
9656 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9657 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
9658 full_cls = if full_active { " active" } else { "" },
9659 super_cls = if super_scope_active { " active" } else { "" },
9660 );
9661 for s in sub_names {
9662 let is_active = active_sub == Some(s.as_str());
9663 write!(
9664 bar,
9665 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9666 cls = if is_active { " active" } else { "" },
9667 name_enc = html_escape(s),
9668 name_esc = html_escape(s),
9669 )
9670 .unwrap();
9671 }
9672 bar.push_str("\n</div>");
9673 bar
9674}
9675
9676fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9678 active_sub.map_or_else(
9679 || {
9680 if super_scope_active {
9681 "Super-repo only — ".to_string()
9682 } else {
9683 String::new()
9684 }
9685 },
9686 |s| format!("Submodule: {} — ", html_escape(s)),
9687 )
9688}
9689
9690#[allow(clippy::too_many_lines)]
9691#[allow(clippy::too_many_arguments)]
9692fn multi_compare_page(
9693 multi: &MultiScanComparison,
9694 project_label: &str,
9695 version: &str,
9696 csp_nonce: &str,
9697 has_submodule_data: bool,
9698 sub_names: &[String],
9699 runs_csv: &str,
9700 super_scope_active: bool,
9701 active_sub: Option<&str>,
9702 entries: &[RegistryEntry],
9703) -> String {
9704 let n = multi.points.len();
9705 let is_many = n > 4;
9706 let mc_strip_class = if is_many {
9707 "mc-strip mc-strip-grid"
9708 } else {
9709 "mc-strip"
9710 };
9711
9712 let scan_strip = build_mc_scan_strip(
9714 multi,
9715 entries,
9716 n,
9717 is_many,
9718 active_sub,
9719 super_scope_active,
9720 project_label,
9721 );
9722
9723 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9725
9726 let points_json = build_mc_points_json(multi, entries);
9728 let file_matrix_json = build_mc_file_matrix_json(multi);
9729
9730 let files_modified = multi
9732 .file_matrix
9733 .iter()
9734 .filter(|f| f.overall_status == "modified")
9735 .count();
9736 let files_added = multi
9737 .file_matrix
9738 .iter()
9739 .filter(|f| f.overall_status == "added")
9740 .count();
9741 let files_removed = multi
9742 .file_matrix
9743 .iter()
9744 .filter(|f| f.overall_status == "removed")
9745 .count();
9746 let files_unchanged = multi
9747 .file_matrix
9748 .iter()
9749 .filter(|f| f.overall_status == "unchanged")
9750 .count();
9751 let total_files = multi.file_matrix.len();
9752
9753 let file_col_headers = build_mc_file_col_headers(n);
9754 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9755 let scope_bar_html = build_mc_scope_bar(
9756 has_submodule_data,
9757 sub_names,
9758 runs_csv,
9759 active_sub,
9760 super_scope_active,
9761 );
9762 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9763 let toast_assets = sloc_toast_assets(csp_nonce);
9764
9765 format!(
9766 r#"<!doctype html>
9767<html lang="en">
9768<head>
9769 <meta charset="utf-8">
9770 <meta name="viewport" content="width=device-width, initial-scale=1">
9771 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9772 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9773 <style nonce="{csp_nonce}">
9774 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
9775 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9776 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9777 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;}}
9778 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9779 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9780 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9781 .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;}}
9782 @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));}}}}
9783 .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);}}
9784 .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;}}
9785 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9786 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9787 @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;}}}}
9788 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9789 .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));}}
9790 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9791 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9792 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9793 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9794 .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;}}
9795 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9796 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9797 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9798 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9799 .nav-dropdown{{position:relative;display:inline-flex;}}
9800 .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;}}
9801 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9802 .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;}}
9803 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9804 .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);}}
9805 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9806 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9807 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9808 body:not(.dark-theme) .icon-sun{{display:none;}}
9809 body.dark-theme .icon-moon{{display:none;}}
9810 .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;}}
9811 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9812 .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);}}
9813 .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;}}
9814 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9815 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9816 .settings-modal-body{{padding:14px 16px 16px;}}
9817 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9818 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9819 .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;}}
9820 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9821 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9822 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9823 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9824 .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;}}
9825 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9826 .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;}}
9827 .btn-back:hover{{background:var(--line);}}
9828 .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;}}
9829 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;}}
9830 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9831 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9832 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9833 .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;}}
9834 .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;}}
9835 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9836 .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;}}
9837 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9838 body.dark-theme .mc-card{{background:var(--surface-2);}}
9839 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9840 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9841 .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%;}}
9842 .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;}}
9843 .mc-card-commit:hover{{color:var(--oxide);}}
9844 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9845 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9846 .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;}}
9847 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9848 .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;}}
9849 .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;}}
9850 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9851 .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;}}
9852 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
9853 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
9854 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
9855 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
9856 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
9857 .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);}}
9858 .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);}}
9859 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
9860 .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;}}
9861 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
9862 .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;}}
9863 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
9864 .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;}}
9865 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
9866 .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;}}
9867 .submod-scope-btn:hover{{background:var(--line);}}
9868 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9869 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
9870 .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;}}
9871 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
9872 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9873 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
9874 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
9875 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
9876 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
9877 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
9878 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
9879 .metrics-table .pos{{color:var(--pos);}}
9880 .metrics-table .neg{{color:var(--neg);}}
9881 .metrics-table .zero{{color:var(--muted);}}
9882 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
9883 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9884 .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;}}
9885 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9886 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
9887 .chart-wrap{{width:100%;overflow-x:auto;}}
9888 #mc-chart{{display:block;width:100%;}}
9889 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
9890 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
9891 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
9892 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
9893 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9894 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
9895 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
9896 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
9897 .ic-card-h2-row .ic-card-h2{{margin:0;}}
9898 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9899 .ic-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
9900 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9901 .ic-svg-modal-ov{{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
9902 .ic-svg-modal-ov.open{{display:flex;}}
9903 .ic-svg-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:900px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
9904 .ic-svg-modal-hdr{{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}}
9905 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
9906 .ic-svg-modal-close{{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}}
9907 .ic-svg-modal-close:hover{{background:var(--line);}}
9908 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
9909 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
9910 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
9911 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
9912 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
9913 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
9914 #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;}}
9915 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9916 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
9917 .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;}}
9918 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9919 .tab-btn:hover:not(.active){{background:var(--line);}}
9920 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
9921 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
9922 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
9923 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
9924 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
9925 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
9926 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
9927 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
9928 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
9929 .table-wrap{{width:100%;overflow-x:auto;}}
9930 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
9931 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
9932 #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;}}
9933 #file-table th.left,#file-table td.left{{text-align:left;}}
9934 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
9935 .file-delta-col{{color:var(--muted);font-size:11px;}}
9936 .file-net-col{{font-weight:800;}}
9937 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
9938 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
9939 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
9940 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
9941 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
9942 .status-badge.modified{{background:#fff2d8;color:#926000;}}
9943 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
9944 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
9945 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
9946 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
9947 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
9948 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
9949 tr.row-added td{{background:rgba(26,143,71,0.04);}}
9950 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9951 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9952 tr.row-unchanged td{{color:var(--muted);}}
9953 tr.row-unchanged .status-badge{{opacity:.65;}}
9954 .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;}}
9955 .absent{{color:var(--muted);font-style:italic;}}
9956 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9957 .pagination-info{{font-size:12px;color:var(--muted);}}
9958 .pagination-btns{{display:flex;gap:5px;}}
9959 .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;}}
9960 .pg-btn:hover:not(:disabled){{background:var(--line);}}
9961 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9962 .pg-btn:disabled{{opacity:.35;cursor:default;}}
9963 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;}}
9964 .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;}}
9965 .export-btn:hover{{background:var(--line);}}
9966 .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;}}
9967 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9968 .site-footer a{{color:var(--muted);}}
9969 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;}}
9970 body.pdf-mode{{background:#fff!important;}}
9971 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
9972 .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;}}
9973 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9974 .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;}}
9975 .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;}}
9976 .mc-modal-title{{font-size:18px;font-weight:800;}}
9977 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9978 .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;}}
9979 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
9980 .mc-modal-body{{padding:18px 22px;}}
9981 .mc-modal-sec{{margin-bottom:20px;}}
9982 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
9983 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
9984 .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;}}
9985 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
9986 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
9987 .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;}}
9988 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
9989 .mc-modal-row:last-child{{border-bottom:none;}}
9990 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
9991 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
9992 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
9993 .mc-modal-val a:hover{{text-decoration:underline;}}
9994 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
9995 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
9996 .mc-modal-stat[data-tip]{{cursor:help;}}
9997 #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);}}
9998 .mc-card{{cursor:pointer;}}
9999 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10000 </style>
10001</head>
10002<body>
10003 {loading_overlay}
10004 <div class="background-watermarks" aria-hidden="true">
10005 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10006 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10007 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10008 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10009 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10010 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10011 </div>
10012 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10013 <div class="top-nav">
10014 <div class="top-nav-inner">
10015 <a class="brand" href="/">
10016 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10017 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10018 </a>
10019 <div class="nav-right">
10020 <a class="nav-pill" href="/">Home</a>
10021 <div class="nav-dropdown">
10022 <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>
10023 <div class="nav-dropdown-menu">
10024 <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>
10025 </div>
10026 </div>
10027 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10028 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10029 <div class="nav-dropdown">
10030 <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>
10031 <div class="nav-dropdown-menu">
10032 <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>
10033 </div>
10034 </div>
10035 <div class="server-status-wrap" id="server-status-wrap">
10036 <div class="nav-pill server-online-pill" id="server-status-pill">
10037 <span class="status-dot" id="status-dot"></span>
10038 <span id="server-status-label">Server</span>
10039 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10040 </div>
10041 <div class="server-status-tip">
10042 OxideSLOC is running — accessible on your network.
10043 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10044 </div>
10045 </div>
10046 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10047 <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>
10048 </button>
10049 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10050 <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>
10051 <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>
10052 </button>
10053 </div>
10054 </div>
10055 </div>
10056
10057 <div class="page">
10058 <!-- Hero header -->
10059 <div class="mc-hero">
10060 <div class="mc-hero-header">
10061 <div>
10062 <div class="mc-title">Multi-Scan Timeline</div>
10063 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10064 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10065 </div>
10066 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10067 <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>
10068 <div class="export-group" id="mc-top-export-group">
10069 <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>
10070 <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>
10071 </div>
10072 </div>
10073 </div>
10074 {scope_bar_html}
10075 <!-- Scan strip -->
10076 <div class="{mc_strip_class}">{scan_strip}</div>
10077 </div>
10078
10079 <!-- Summary metrics table -->
10080 <div class="panel">
10081 <div class="panel-title">Metric Progression</div>
10082 <div class="table-wrap">
10083 <table class="metrics-table">
10084 <thead>{metrics_thead}</thead>
10085 <tbody>{metrics_tbody}</tbody>
10086 </table>
10087 </div>
10088 </div>
10089
10090 <!-- Scan Charts -->
10091 <div class="panel" id="mc-charts-panel">
10092 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10093 <div class="ic-grid">
10094 <!-- Timeline line chart — spans full width -->
10095 <div class="ic-card" style="grid-column:span 2">
10096 <div class="ic-card-h2-row">
10097 <span class="ic-card-h2">Timeline</span>
10098 <div class="chart-toolbar" style="margin:0">
10099 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10100 <button class="chart-metric-btn" data-metric="files">Files</button>
10101 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10102 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10103 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10104 </div>
10105 </div>
10106 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10107 </div>
10108 <!-- Code Metrics: Scan 1 vs Latest -->
10109 <div class="ic-card">
10110 <div class="ic-chart-hdr"><span class="ic-card-h2">Code Metrics — Scan 1 vs Latest</span><button class="ic-expand-btn" data-expand-src="mc-ic-c1" data-expand-title="Code Metrics — Scan 1 vs Latest">⤢ Full View</button></div>
10111 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = scan 1)</span></div>
10112 <div id="mc-ic-c1"></div>
10113 </div>
10114 <!-- Language Code Delta -->
10115 <div class="ic-card" id="mc-ic-lang-card">
10116 <div class="ic-chart-hdr"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="mc-ic-c3" data-expand-title="Language Code Delta">⤢ Full View</button></div>
10117 <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10118 <div id="mc-ic-c3"></div>
10119 </div>
10120 <!-- Delta by Metric -->
10121 <div class="ic-card">
10122 <div class="ic-chart-hdr"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="mc-ic-c2" data-expand-title="Delta by Metric">⤢ Full View</button></div>
10123 <div id="mc-ic-c2"></div>
10124 </div>
10125 <!-- File Change Distribution -->
10126 <div class="ic-card">
10127 <div class="ic-chart-hdr"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="mc-ic-c4" data-expand-title="File Change Distribution">⤢ Full View</button></div>
10128 <div id="mc-ic-c4"></div>
10129 </div>
10130 </div>
10131 </div>
10132
10133 <!-- File matrix table -->
10134 <div class="panel">
10135 <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>
10136 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10137 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10138 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10139 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10140 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10141 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10142 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10143 </div>
10144 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10145 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10146 <div class="export-group">
10147 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10148 <button type="button" class="export-btn" id="export-csv-btn">
10149 <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>
10150 CSV
10151 </button>
10152 <button type="button" class="export-btn" id="mc-file-xls-btn">
10153 <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>
10154 Excel
10155 </button>
10156 </div>
10157 </div>
10158 </div>
10159 <div class="table-wrap">
10160 <table id="file-table">
10161 <thead>
10162 <tr>
10163 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10164 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10165 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10166 {file_col_headers}
10167 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10168 </tr>
10169 </thead>
10170 <tbody id="file-tbody"></tbody>
10171 </table>
10172 </div>
10173 <div class="pagination">
10174 <span class="pagination-info" id="pg-info"></span>
10175 <div class="pagination-btns" id="pg-btns"></div>
10176 <div style="display:flex;align-items:center;gap:6px;">
10177 <span style="font-size:12px;color:var(--muted)">Show</span>
10178 <select class="per-page" id="per-page-sel">
10179 <option value="25" selected>25 per page</option>
10180 <option value="50">50 per page</option>
10181 <option value="100">100 per page</option>
10182 </select>
10183 </div>
10184 </div>
10185 </div>
10186 </div>
10187
10188 <div id="mc-ic-tt"></div>
10189
10190 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10191 <div class="ic-svg-modal">
10192 <div class="ic-svg-modal-hdr">
10193 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10194 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10195 </div>
10196 <div id="ic-svg-modal-body"></div>
10197 </div>
10198 </div>
10199
10200 <footer class="site-footer">
10201 oxide-sloc v{version} — local code metrics workbench ·
10202 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10203 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10204 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10205 · <a href="/api-docs" rel="noopener">REST API</a>
10206 </footer>
10207
10208 <script nonce="{csp_nonce}">
10209 (function(){{
10210 // ── Dark theme ───────────────────────────────────────────────────────────
10211 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10212 var renderInlineCharts=null;
10213 var tt=document.getElementById('theme-toggle');
10214 if(tt)tt.addEventListener('click',function(){{
10215 var on=document.body.classList.toggle('dark-theme');
10216 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10217 renderChart(activeMetric);
10218 if(renderInlineCharts)renderInlineCharts();
10219 }});
10220
10221 // ── Code particles ───────────────────────────────────────────────────────
10222 var container=document.getElementById('code-particles');
10223 if(container){{
10224 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()'];
10225 for(var i=0;i<28;i++){{
10226 (function(idx){{
10227 var el=document.createElement('span');el.className='code-particle';
10228 el.textContent=snips[idx%snips.length];
10229 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10230 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10231 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10232 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10233 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10234 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10235 container.appendChild(el);
10236 }})(i);
10237 }}
10238 }}
10239
10240 // ── Watermarks ───────────────────────────────────────────────────────────
10241 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10242 if(wms.length){{
10243 var placed=[];
10244 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;}}
10245 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];}}
10246 var half=Math.floor(wms.length/2);
10247 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;}});
10248 }}
10249
10250 // ── Settings / colour scheme modal ───────────────────────────────────────
10251 (function(){{
10252 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'}}];
10253 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);}});}}
10254 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10255 function init(){{
10256 var btn=document.getElementById('settings-btn');if(!btn)return;
10257 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10258 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>';
10259 document.body.appendChild(m);
10260 var g=document.getElementById('scheme-grid');
10261 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);}});
10262 var cl=document.getElementById('settings-close-btn');
10263 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');}});
10264 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10265 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10266 }}
10267 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10268 }})();
10269
10270 // ── Timezone support for scan timestamps ─────────────────────────────────
10271 (function(){{
10272 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';}};
10273 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'';}}}};
10274 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);}});}};
10275 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10276 window.applyTz(storedTz);
10277 function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
10278 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10279 }})();
10280
10281 // ── Data ────────────────────────────────────────────────────────────────
10282 var POINTS={points_json};
10283 var FILES={file_matrix_json};
10284 var N={n};
10285
10286 // ── fmt helper ───────────────────────────────────────────────────────────
10287 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();}}
10288 function fmtFull(n){{return Number(n).toLocaleString();}}
10289 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10290
10291 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10292 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10293 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));}}
10294 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10295 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10296
10297 // ── Timeline chart ───────────────────────────────────────────────────────
10298 var activeMetric='code';
10299 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10300 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10301
10302 function renderChart(metric){{
10303 var svg=document.getElementById('mc-chart');if(!svg)return;
10304 var W=svg.getBoundingClientRect().width||800,H=280;
10305 svg.setAttribute('height',H);
10306 var pad={{l:62,r:20,t:32,b:72}};
10307 var dark=document.body.classList.contains('dark-theme');
10308 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10309 var valid=pts.filter(function(v){{return v!=null;}});
10310 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;}}
10311 var minV=0,maxV=Math.max.apply(null,valid);
10312 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10313 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10314 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10315 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10316 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10317 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10318 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10319 var parts=[];
10320 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10321 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>');}}
10322 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10323 var lineD='';var firstPt=true;
10324 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);}}}}
10325 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10326 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10327 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10328 for(var i=0;i<N;i++){{
10329 if(pts[i]==null)continue;
10330 var cx=xOf(i),cy=yOf(pts[i]);
10331 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10332 var hasTag=p.tags&&p.tags.length>0;
10333 // Permanent Y-value label above the dot
10334 parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtFull(pts[i])+'</text>');
10335 parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
10336 var xanchor=i===0?'start':i===N-1?'end':'middle';
10337 // X-axis label at 2× the original size (18 px)
10338 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>');
10339 }}
10340 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10341 svg.setAttribute('viewBox','0 0 '+W+' '+H);
10342 svg.innerHTML=parts.join('');
10343 svg.addEventListener('click',function(e){{var c=e.target.closest('circle[data-run-id]');if(c)window.location='/runs/html/'+c.getAttribute('data-run-id');}});
10344 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10345 svg.onmousemove=function(e){{
10346 var rect=svg.getBoundingClientRect();
10347 var scaleX=W/rect.width;
10348 var mouseX=(e.clientX-rect.left)*scaleX;
10349 var nearest=-1,minDist=Infinity;
10350 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;}}}}
10351 if(nearest<0)return;
10352 var nc=xOf(nearest),ny=yOf(pts[nearest]);
10353 var xhair=svg.querySelector('.mc-xhair');
10354 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10355 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"/>';
10356 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10357 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10358 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>';
10359 var bx=rect.left+(nc/W*rect.width)+18;
10360 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10361 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10362 }};
10363 svg.onmouseleave=function(){{
10364 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10365 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10366 }};
10367 }}
10368
10369 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10370
10371 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10372 btn.addEventListener('click',function(){{
10373 activeMetric=this.dataset.metric;
10374 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10375 this.classList.add('active');
10376 renderChart(activeMetric);
10377 }});
10378 }});
10379 if(typeof ResizeObserver!=='undefined'){{
10380 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10381 }}
10382 renderChart(activeMetric);
10383
10384 // ── File matrix table ────────────────────────────────────────────────────
10385 var activeStatus='';
10386 var currentPage=1;
10387 var perPage=25;
10388 var mcSortCol=null,mcSortAsc=true;
10389
10390 function getFiltered(){{
10391 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10392 if(!mcSortCol)return data;
10393 var asc=mcSortAsc;
10394 return data.slice().sort(function(a,b){{
10395 var va,vb;
10396 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10397 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10398 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10399 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10400 else{{return 0;}}
10401 if(asc)return va<vb?-1:va>vb?1:0;
10402 return va<vb?1:va>vb?-1:0;
10403 }});
10404 }}
10405
10406 function renderFilePage(){{
10407 var filtered=getFiltered();
10408 var total=filtered.length;
10409 var totalPages=Math.max(1,Math.ceil(total/perPage));
10410 if(currentPage>totalPages)currentPage=totalPages;
10411 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10412 var tbody=document.getElementById('file-tbody');if(!tbody)return;
10413 var rows=[];
10414 for(var i=start;i<end;i++){{
10415 var f=filtered[i];
10416 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10417 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10418 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10419 for(var j=0;j<N;j++){{
10420 var cv=f.c[j];
10421 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10422 if(j<N-1){{
10423 var dv=f.d[j+1];
10424 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10425 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10426 }}
10427 }}
10428 var tc=f.t;
10429 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10430 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10431 }}
10432 tbody.innerHTML=rows.join('');
10433
10434 var info=document.getElementById('pg-info');
10435 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10436 renderPgBtns(totalPages);
10437 }}
10438
10439 function renderPgBtns(totalPages){{
10440 var wrap=document.getElementById('pg-btns');if(!wrap)return;
10441 var btns=[];
10442 function mkBtn(label,page,active,disabled){{
10443 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10444 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10445 }}
10446 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
10447 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10448 if(s>1)btns.push(mkBtn('1',1,false,false));
10449 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10450 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10451 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10452 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10453 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
10454 wrap.innerHTML=btns.join('');
10455 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10456 b.addEventListener('click',function(){{
10457 var pg=parseInt(this.dataset.pg,10);
10458 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10459 }});
10460 }});
10461 }}
10462
10463 // Tab filter
10464 document.querySelectorAll('.tab-btn').forEach(function(btn){{
10465 btn.addEventListener('click',function(){{
10466 activeStatus=this.dataset.status||'';
10467 currentPage=1;
10468 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10469 this.classList.add('active');
10470 renderFilePage();
10471 }});
10472 }});
10473
10474 // Per-page selector
10475 var ppSel=document.getElementById('per-page-sel');
10476 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10477
10478 // ── Column header sort ───────────────────────────────────────────────────
10479 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10480 th.addEventListener('click',function(){{
10481 var col=th.dataset.sortCol;
10482 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10483 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10484 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10485 }});
10486 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10487 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
10488 currentPage=1;renderFilePage();
10489 }});
10490 }});
10491
10492 // Reset button also clears sort
10493 var mcResetBtn=document.getElementById('mc-file-reset-btn');
10494 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10495 mcSortCol=null;mcSortAsc=true;
10496 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10497 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10498 }});
10499 activeStatus='';currentPage=1;
10500 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10501 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10502 renderFilePage();
10503 }});
10504
10505 renderFilePage();
10506
10507 // ── CSV export ───────────────────────────────────────────────────────────
10508 var exportBtn=document.getElementById('export-csv-btn');
10509 if(exportBtn)exportBtn.addEventListener('click',function(){{
10510 var header=['File','Language','Status'];
10511 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10512 header.push('Net Delta');
10513 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10514 var filtered=getFiltered();
10515 filtered.forEach(function(f){{
10516 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10517 for(var j=0;j<N;j++){{
10518 cols.push(f.c[j]!=null?f.c[j]:'');
10519 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10520 }}
10521 cols.push(f.t);
10522 rows.push(cols.join(','));
10523 }});
10524 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10525 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10526 a.download=mcExportName('csv');a.click();
10527 }});
10528
10529 // ── File matrix extra export buttons ─────────────────────────────────────
10530 (function(){{
10531 var resetBtn=document.getElementById('mc-file-reset-btn');
10532 if(resetBtn)resetBtn.addEventListener('click',function(){{
10533 activeStatus='';currentPage=1;
10534 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10535 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10536 renderFilePage();
10537 }});
10538
10539 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10540 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10541 function mcMakeXlsx(fname){{
10542 var filtered=getFiltered();
10543 var enc=new TextEncoder();
10544 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;}}
10545 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;}}
10546 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10547 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10548 var ss=[],si={{}};
10549 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10550 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10551 function WS(){{
10552 var R=0,buf=[];
10553 function cl(c){{return String.fromCharCode(65+c);}}
10554 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10555 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10556 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10557 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>';}}
10558 return{{sc:sc,nc:nc,row:row,xml:xml}};
10559 }}
10560 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10561 var proj=mcExportProj();
10562 // \u2500\u2500 Summary sheet \u2500\u2500
10563 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10564 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10565 r1(s1(0,proj,2));
10566 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10567 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
10568 r1('');
10569 r1(s1(0,'SCAN SUMMARY',8));
10570 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));
10571 POINTS.forEach(function(p,i){{
10572 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10573 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));
10574 }});
10575 r1('');
10576 if(POINTS.length>1){{
10577 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10578 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10579 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10580 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)));}};
10581 nr('Code Lines',pf.code,pl.code);
10582 nr('Comment Lines',pf.comments,pl.comments);
10583 nr('Files Analyzed',pf.files,pl.files);
10584 nr('Tests',pf.tests,pl.tests);
10585 r1('');
10586 }}
10587 var cMod=0,cAdd=0,cRem=0,cUnch=0;
10588 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10589 var totF=FILES.length||1;
10590 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10591 r1(s1(0,'FILE CHANGES',8));
10592 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10593 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10594 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10595 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10596 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10597 var lm={{}};
10598 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;}});
10599 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10600 if(langs.length){{
10601 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10602 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10603 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)));}});
10604 }}
10605 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10606 // \u2500\u2500 File Delta sheet \u2500\u2500
10607 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10608 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10609 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);}}
10610 hcells+=s2(hc,'Net Delta',3);
10611 r2(hcells);
10612 filtered.forEach(function(f){{
10613 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10614 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));}}}}
10615 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10616 r2(cells);
10617 }});
10618 var ncols=3+N+(N-1)+1;
10619 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10620 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>';
10621 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10622 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>',
10623 '_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>',
10624 '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>',
10625 '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>',
10626 '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>',
10627 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10628 var zparts=[],zcds=[],zoff=0,znf=0;
10629 ['[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){{
10630 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10631 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]);
10632 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);
10633 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));
10634 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10635 zoff+=entry.length;znf++;
10636 }});
10637 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10638 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]);
10639 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10640 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10641 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10642 out.set(new Uint8Array(eocd),pos);
10643 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10644 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10645 }}
10646
10647 var xlsBtn=document.getElementById('mc-file-xls-btn');
10648 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10649
10650 // File matrix HTML export — interactive: sort by column, filter by status
10651 function mcFileBuildHtml(){{
10652 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10653 var hdrs=['File','Language','Status'];
10654 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10655 hdrs.push('Net \u0394');
10656 var SI=2;
10657 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;}});
10658 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10659 var cnt={{all:allRows.length}};
10660 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10661 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10662 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10663 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10664 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10665 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10666 '.sub{{font-size:12px;color:#99aabb;}}'+
10667 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10668 '.wr{{padding:16px 20px;}}'+
10669 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10670 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10671 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10672 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10673 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10674 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10675 'thead tr{{background:#1a2035;}}'+
10676 '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;}}'+
10677 'th:hover{{background:#2a3050;}}'+
10678 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10679 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10680 'tr:nth-child(even) td{{background:#faf7f4;}}'+
10681 'tr:hover td{{background:#f5f0ea;}}'+
10682 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10683 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10684 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10685 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10686 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10687 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10688 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10689 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10690 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10691 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
10692 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
10693 '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>";}}'+
10694 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10695 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10696 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
10697 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10698 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10699 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10700 '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("")'+
10701 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10702 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10703 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10704 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10705 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10706 'sd=(sc===ci)?-sd:1;sc=ci;'+
10707 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10708 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10709 'render();';
10710 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10711 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10712 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
10713 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10714 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10715 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10716 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10717 '<script>'+inlineJs+'<\/script><\/body><\/html>';
10718 }}
10719
10720 var htmlBtn=document.getElementById('mc-file-html-btn');
10721 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10722 var h=mcFileBuildHtml();
10723 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10724 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10725 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10726 }});
10727
10728 var pdfBtn=document.getElementById('mc-file-pdf-btn');
10729 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10730 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10731 }});
10732 }})();
10733
10734 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10735 (function(){{
10736 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10737 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10738 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10739 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10740 function fmt2(n){{return Number(n).toLocaleString();}}
10741 function px(n){{return Math.round(n);}}
10742 var _tt=document.getElementById('mc-ic-tt');
10743 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10744 function addTT(el){{
10745 if(!el)return;
10746 el.addEventListener('mouseover',function(e){{
10747 var t=e.target.closest('[data-ttl]');
10748 if(t&&_tt){{
10749 var ttl=t.getAttribute('data-ttl');
10750 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10751 _tt.style.display='block';mvTT(e);
10752 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10753 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10754 }} else {{
10755 if(_tt)_tt.style.display='none';
10756 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10757 }}
10758 }});
10759 el.addEventListener('mouseleave',function(){{
10760 if(_tt)_tt.style.display='none';
10761 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10762 }});
10763 el.addEventListener('mousemove',function(e){{mvTT(e);}});
10764 }}
10765 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';}}
10766 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10767 function buildCharts(){{
10768 if(N<2)return;
10769 var cs=getComputedStyle(document.body);
10770 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10771 var textCol=cv('--text','#43342d');
10772 var mutedCol=cv('--muted','#7b675b');
10773 var gFill=cv('--muted-2','#a08777');
10774 var LGY=cv('--line','#e6d0bf');
10775 var axisCol=cv('--line-strong','#d8bfad');
10776 var surf2col=cv('--surface-2','#f4ede4');
10777 var surfCol=cv('--surface','#fff8f0');
10778 var p0=POINTS[0],pLast=POINTS[N-1];
10779 var dark=document.body.classList.contains('dark-theme');
10780 var FADE=dark?'#524238':'#e6d0bf';
10781 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10782 function niceMax(v){{var x=v||1;var p=Math.pow(10,Math.floor(Math.log10(x)));var n=x/p;var s=n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10;return s*p;}}
10783 var c1mets=[
10784 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10785 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10786 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10787 ];
10788 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10789 // Code Metrics chart — grows to fill the height its grid row settled to (the
10790 // Language Code Delta sibling usually drives that), so it never sits short at
10791 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10792 function drawC1(){{
10793 var C1W=620,C1H=200;
10794 var c1host=document.getElementById('mc-ic-c1');
10795 var c1card=c1host?c1host.closest('.ic-card'):null;
10796 if(c1host&&c1card&&c1host.clientWidth>0){{
10797 var avW=c1host.clientWidth;
10798 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10799 var wantH=availPx*C1W/avW;
10800 if(wantH>C1H)C1H=wantH;
10801 }}
10802 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10803 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10804 for(var gi=1;gi<=4;gi++){{
10805 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10806 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10807 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10808 }}
10809 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10810 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10811 c1mets.forEach(function(m,i){{
10812 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10813 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10814 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10815 c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
10816 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.b)+'</text>';
10817 c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
10818 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.c)+'</text>';
10819 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Scan 1</text>';
10820 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Latest</text>';
10821 }});
10822 c1+='</svg>';
10823 return c1;
10824 }}
10825 // Chart 2: Delta by Metric (net delta first scan to last)
10826 var mets=[
10827 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10828 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10829 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10830 ];
10831 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10832 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;
10833 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10834 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10835 mets.forEach(function(m,i){{
10836 var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,vcol=(m.v===0?textCol:col),bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt2(m.v);
10837 c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
10838 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;"/>';
10839 if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
10840 else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
10841 }});
10842 c2+='</svg>';
10843 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10844 var lm={{}};
10845 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;}});
10846 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10847 function drawC3(){{
10848 if(!langs.length)return'';
10849 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10850 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;
10851 var c3host=document.getElementById('mc-ic-c3');
10852 var c3card=document.getElementById('mc-ic-lang-card');
10853 var C3H=langs.length*30+24;
10854 if(c3host&&c3card&&c3host.clientWidth>0){{
10855 var avW=c3host.clientWidth;
10856 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
10857 var wantH=availPx*C3W/avW;
10858 if(wantH>C3H)C3H=wantH;
10859 }}
10860 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
10861 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10862 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10863 langs.forEach(function(l,i){{
10864 var e=lm[l],yc=topPad+band*(i+0.5),bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
10865 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
10866 c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+px(yc-barH/2)+'" width="'+px(bw)+'" height="'+px(barH)+'" fill="'+col+'" rx="3"/>';
10867 if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+px(yc+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
10868 else{{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+px(yc+4)+'" text-anchor="'+anc3+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
10869 c3+='<text x="'+(C3W-5)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
10870 }});
10871 c3+='</svg>';
10872 return c3;
10873 }}
10874 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
10875 var fm=0,fa=0,fr=0,fu=0;
10876 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
10877 var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:FADE}}].filter(function(s){{return s.v>0;}});
10878 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
10879 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
10880 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
10881 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
10882 if(segs.length===1){{
10883 c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
10884 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
10885 c4+='<text x="'+cx4+'" y="'+px(cy4-(Ro+Ri)/2+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+pctFill(segs[0].c)+'">100%</text>';
10886 }} else {{
10887 segs.forEach(function(s){{
10888 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
10889 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);
10890 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);
10891 c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
10892 if(sw>0.32){{var midA=ang4+sw/2,rr=(Ro+Ri)/2,lx=cx4+rr*Math.cos(midA),ly=cy4+rr*Math.sin(midA);c4+='<text x="'+px(lx)+'" y="'+px(ly+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="700" fill="'+pctFill(s.c)+'">'+px(s.v/tot4*100)+'%</text>';}}
10893 ang4+=sw;
10894 }});
10895 }}
10896 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
10897 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
10898 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
10899 segs.forEach(function(s,i){{
10900 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
10901 c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
10902 c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
10903 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
10904 }});
10905 c4+='</svg>';
10906 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
10907 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
10908 // once at natural height to seed the row, then both are filled to the row the
10909 // grid settled to, so neither sits short at the top of an over-tall cell.
10910 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
10911 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
10912 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
10913 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
10914 var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
10915 if(e1)e1.innerHTML=drawC1();
10916 }}
10917 buildCharts();
10918 renderInlineCharts=buildCharts;
10919 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
10920 (function(){{
10921 var ov=document.getElementById('ic-svg-modal-ov');
10922 var body=document.getElementById('ic-svg-modal-body');
10923 var ttl=document.getElementById('ic-svg-modal-title');
10924 var closeBtn=document.getElementById('ic-svg-modal-close');
10925 if(!ov||!body)return;
10926 function close(){{ov.classList.remove('open');body.innerHTML='';}}
10927 function open(srcId,title){{
10928 var src=document.getElementById(srcId);if(!src)return;
10929 ttl.textContent=title||'';
10930 var card=src.closest('.ic-card');
10931 var legHtml='';
10932 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
10933 body.innerHTML=legHtml+src.innerHTML;
10934 var svg=body.querySelector('svg');
10935 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
10936 addTT(body);
10937 ov.classList.add('open');
10938 }}
10939 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
10940 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
10941 }});
10942 if(closeBtn)closeBtn.addEventListener('click',close);
10943 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
10944 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
10945 }})();
10946
10947 // HTML legend hover → highlight matching SVG bars within the SAME card only
10948 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
10949 var metric=leg.getAttribute('data-highlight');
10950 var parentCard=leg.closest('.ic-card');
10951 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
10952 if(!chartEl)return;
10953 leg.addEventListener('mouseenter',function(){{
10954 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
10955 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
10956 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
10957 x.style.opacity='1';
10958 }} else {{
10959 x.style.opacity='0.28';
10960 }}
10961 }});
10962 }});
10963 leg.addEventListener('mouseleave',function(){{
10964 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10965 }});
10966 }});
10967 // Author handles
10968 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
10969
10970 // ── Export helpers ────────────────────────────────────────────────────────
10971 // Fetch one image from the server and return a data-URI Promise
10972 function mcFetchUri(path){{
10973 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
10974 return new Promise(function(res){{
10975 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
10976 }});
10977 }}).catch(function(){{return '';}});
10978 }}
10979 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
10980 function mcInlineImgs(html,cb){{
10981 var paths=[],seen={{}};
10982 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
10983 if(!paths.length){{cb(html);return;}}
10984 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
10985 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
10986 .catch(function(){{cb(html);}});
10987 }}
10988 // Capture full-page HTML with all table rows visible
10989 function mcRawHtml(pdfMode){{
10990 if(pdfMode)document.body.classList.add('pdf-mode');
10991 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
10992 var html=document.documentElement.outerHTML;
10993 perPage=s;currentPage=p;renderFilePage();
10994 if(pdfMode)document.body.classList.remove('pdf-mode');
10995 return html;
10996 }}
10997
10998 // HTML export (full page with inlined images)
10999 function mcDoHtml(btn,fname){{
11000 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11001 mcInlineImgs(mcRawHtml(false),function(html){{
11002 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11003 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11004 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11005 btn.disabled=false;btn.innerHTML=orig;
11006 }});
11007 }}
11008 // PDF export — comprehensive document-style report: full numbers, all sections
11009 function mcBuildPdfHtml(){{
11010 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11011 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11012 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11013 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>';}}
11014 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11015 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11016 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)));}}
11017 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11018 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11019 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11020 // Header/footer flow in document order (NOT position:fixed) — a fixed
11021 // header repeats every printed page in Chromium and overlaps the content
11022 // below it, swallowing the first rows of pages 2+ and clipping the cards
11023 // on page 1. The table <thead> repeats per page natively, so every row
11024 // stays visible.
11025 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11026 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11027 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11028 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11029 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11030 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11031 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11032 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11033 '.info-bar{{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11034 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11035 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11036 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11037 '.body{{padding:12px 18px 0;}}'+
11038 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11039 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11040 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11041 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11042 '.sec{{margin-bottom:10px;}}'+
11043 '.sh{{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11044 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11045 'th{{background:#1a2035;color:#fff;padding:4px 7px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11046 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11047 'tr:nth-child(even) td{{background:#faf8f6;}}';
11048 // ── Metric Progression ────────────────────────────────────────────────
11049 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11050 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11051 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>';
11052 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11053 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11054 var progRows=POINTS.map(function(pt,i){{
11055 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)));
11056 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11057 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11058 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11059 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11060 '<td style="text-align:right">'+full(pt.files)+'</td>';
11061 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11062 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11063 return r+'</tr>';
11064 }}).join('');
11065 // ── Scan-to-scan changes ──────────────────────────────────────────────
11066 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11067 var prev=POINTS[i];
11068 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11069 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11070 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11071 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11072 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11073 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11074 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11075 }}).join(''):'';
11076 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11077 var fmSection='';
11078 if(FILES&&FILES.length){{
11079 // Hard cap on per-scan columns so the table never overflows the page width.
11080 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11081 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11082 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11083 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11084 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11085 var fmRows=topFiles.map(function(f){{
11086 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11087 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>';
11088 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11089 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11090 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>';
11091 }}).join('');
11092 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11093 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11094 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11095 }}
11096 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11097 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11098 '<div class="pdf-header"><div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Multi-Scan Timeline</div><div class="ph-date">'+esc(now)+'</div></div><div class="info-bar"><div><div class="ib-name">{project_label}</div></div><div class="ib-right">{n} scans compared<br>'+commitsList+'</div></div></div>'+
11099
11100 '<div class="body">'+
11101 '<div class="sg">'+
11102 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11103 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11104 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11105 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11106 (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>':
11107 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11108 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11109 '</div>'+
11110 '<div class="sec"><p class="sh">Metric Progression</p>'+
11111 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11112 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11113 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11114 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11115 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11116 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11117 fmSection+
11118 '</div>'+
11119 '<div class="pdf-footer"><div class="ftr"><span>oxide-sloc v{version} | AGPL-3.0-or-later</span><span>Multi-Scan Timeline Report</span><span>{project_label} · {n} scans</span></div></div>'+
11120 '</body></html>';
11121 }}
11122 function mcDoPdf(btn){{
11123 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11124 }}
11125
11126 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11127 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11128 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11129 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11130 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11131 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11132 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11133 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11134 if(location.protocol==='file:'){{
11135 [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';}}}} );
11136 [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';}}}} );
11137 }}
11138 }})();
11139 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11140 (function(){{
11141 function $(id){{return document.getElementById(id);}}
11142 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11143 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11144 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11145 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11146 function openModal(idx){{
11147 var ov=$('mc-modal-overlay');if(!ov)return;
11148 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11149 if(idx<0||idx>=N)return;
11150 var pt=POINTS[idx];
11151 titleEl.textContent='Scan '+(idx+1);
11152 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11153 subEl.textContent=lbl;
11154 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11155 '<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>'+
11156 '<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>'+
11157 '<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>'+
11158 '<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>'+
11159 (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>':'')+
11160 (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>':'')+
11161 '</div></div>';
11162 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11163 (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>':'')+
11164 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11165 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11166 (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>':'')+
11167 (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>':'')+
11168 (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>':'')+
11169 (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>':'')+
11170 '<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>'+
11171 '</div>';
11172 var dHtml='';
11173 if(idx>0){{
11174 var prev=POINTS[idx-1];
11175 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11176 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11177 '<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>'+
11178 '<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>'+
11179 '<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>'+
11180 '</div></div>';
11181 }}
11182 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11183 ov.classList.add('open');document.body.style.overflow='hidden';
11184 }}
11185 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11186 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11187 document.addEventListener('click',function(e){{
11188 if(!e.target||!e.target.closest)return;
11189 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11190 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11191 var card=e.target.closest('.mc-card');
11192 if(!card)return;
11193 if(e.target.closest('a'))return;
11194 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11195 var i=cards.indexOf(card);
11196 if(i>=0)openModal(i);
11197 }});
11198 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11199 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11200 var statTip=null;
11201 document.addEventListener('mousemove',function(e){{
11202 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11203 if(!box){{if(statTip)statTip.style.display='none';return;}}
11204 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11205 var tip=box.getAttribute('data-tip')||'';
11206 if(statTip.textContent!==tip)statTip.textContent=tip;
11207 statTip.style.display='block';
11208 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11209 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11210 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11211 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11212 }});
11213 (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');}})();
11214 }})();
11215 }})();
11216 </script>
11217 <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]';
11218 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;}}
11219 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>
11220 <!-- Scan card detail modal -->
11221 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11222 <div class="mc-modal" id="mc-modal">
11223 <div class="mc-modal-head">
11224 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11225 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11226 </div>
11227 <div class="mc-modal-body" id="mc-modal-body"></div>
11228 </div>
11229 </div>
11230 {toast_assets}
11231</body>
11232</html>"#,
11233 project_label = html_escape(project_label),
11234 n = n,
11235 scan_strip = scan_strip,
11236 mc_strip_class = mc_strip_class,
11237 metrics_thead = metrics_thead,
11238 metrics_tbody = metrics_tbody,
11239 file_col_headers = file_col_headers,
11240 total_files = total_files,
11241 files_modified = files_modified,
11242 files_added = files_added,
11243 files_removed = files_removed,
11244 files_unchanged = files_unchanged,
11245 points_json = points_json,
11246 file_matrix_json = file_matrix_json,
11247 nav_compare_active = nav_compare_active,
11248 version = version,
11249 csp_nonce = csp_nonce,
11250 scope_bar_html = scope_bar_html,
11251 scope_label = scope_label,
11252 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11253 )
11254}
11255
11256#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11264 State(state): State<AppState>,
11265 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11266) -> Response {
11267 auto_scan_watched_dirs(&state).await;
11268
11269 let watched_dirs_list: Vec<String> = {
11270 let wd = state.watched_dirs.lock().await;
11271 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11272 };
11273
11274 let roots: Vec<String> = {
11276 let reg = state.registry.lock().await;
11277 let mut seen = std::collections::BTreeSet::new();
11278 reg.entries
11279 .iter()
11280 .flat_map(|e| e.input_roots.iter().cloned())
11281 .filter(|r| seen.insert(r.clone()))
11282 .collect()
11283 };
11284
11285 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11286 let nonce = &csp_nonce;
11287 let version = env!("CARGO_PKG_VERSION");
11288 let toast_assets = sloc_toast_assets(nonce);
11289
11290 let watched_dirs_html: String = if state.server_mode {
11294 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()
11295 } else {
11296 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11297 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11298 .to_string()
11299 } else {
11300 watched_dirs_list
11301 .iter()
11302 .fold(String::new(), |mut s, d| {
11303 use std::fmt::Write as _;
11304 let escaped =
11305 d.replace('&', "&").replace('"', """).replace('<', "<");
11306 write!(
11307 s,
11308 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>"#
11309 ).expect("write to String is infallible");
11310 s
11311 })
11312 };
11313 format!(
11314 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>"#
11315 )
11316 };
11317
11318 let html = format!(
11319 r##"<!doctype html>
11320<html lang="en">
11321<head>
11322 <meta charset="utf-8" />
11323 <meta name="viewport" content="width=device-width, initial-scale=1" />
11324 <title>OxideSLOC | Trend Reports</title>
11325 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11326 <style nonce="{nonce}">
11327 :root {{
11328 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11329 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11330 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11331 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11332 --info-bg:#eef3ff; --info-text:#4467d8;
11333 }}
11334 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11335 *{{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;}}
11336 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11337 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11338 .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;}}
11339 @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));}}}}
11340 .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);}}
11341 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11342 .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));}}
11343 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11344 .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;}}
11345 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11346 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11347 @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; }} }}
11348 .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;}}
11349 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11350 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11351 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11352 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11353 .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;}}
11354 .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;}}
11355 .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;}}
11356 .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;}}
11357 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11358 .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);}}
11359 .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;}}
11360 .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;}}
11361 .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;}}
11362 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11363 .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;}}
11364 .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);}}
11365 .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;}}
11366 .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;}}
11367 .tz-select:focus{{border-color:var(--oxide);}}
11368 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11369 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11370 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11371 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11372 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11373 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11374 .trend-title-block{{flex:1;min-width:0;}}
11375 .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;}}
11376 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11377 .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;}}
11378 .chart-select:focus{{border-color:var(--accent);}}
11379 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11380 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11381 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
11382 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11383 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11384 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11385 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
11386 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11387 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11388 .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;}}
11389 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11390 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11391 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11392 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11393 .tr-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;white-space:nowrap;}}
11394 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11395 .tr-chart-full-modal{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
11396 .tr-chart-full-inner{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1600px;width:100%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
11397 .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;}}
11398 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11399 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11400 .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);}}
11401 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11402 .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;}}
11403 .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;}}
11404 .data-table tr:last-child td{{border-bottom:none;}}
11405 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11406 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11407 .table-wrap{{width:100%;overflow-x:auto;}}
11408 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11409 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11410 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11411 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11412 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11413 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11414 .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;}}
11415 .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;}}
11416 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11417 .pagination-info{{font-size:13px;color:var(--muted);}}
11418 .pagination-btns{{display:flex;gap:6px;}}
11419 .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;}}
11420 .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;}}
11421 #scan-history-table col:nth-child(1){{width:155px;}}
11422 #scan-history-table col:nth-child(2){{width:240px;}}
11423 #scan-history-table col:nth-child(3){{width:82px;}}
11424 #scan-history-table col:nth-child(4){{width:82px;}}
11425 #scan-history-table col:nth-child(5){{width:90px;}}
11426 #scan-history-table col:nth-child(6){{width:90px;}}
11427 #scan-history-table col:nth-child(7){{width:88px;}}
11428 #scan-history-table col:nth-child(8){{width:150px;}}
11429 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11430 .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;}}
11431 .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;}}
11432 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11433 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11434 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11435 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11436 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11437 .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;}}
11438 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11439 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11440 .watched-chip-rm:hover{{color:var(--oxide);}}
11441 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11442 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11443 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11444 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11445 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11446 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11447 a.run-link:hover{{text-decoration:underline;}}
11448 .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);}}
11449 .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);}}
11450 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11451 .metric-num{{font-weight:700;color:var(--text);}}
11452 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11453 .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;}}
11454 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11455 .btn.primary:hover{{opacity:.9;}}
11456 .rpt-btn{{min-width:58px;justify-content:center;}}
11457 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11458 .report-cell{{overflow:visible!important;white-space:normal!important;}}
11459 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11460 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11461 .submod-details summary::-webkit-details-marker{{display:none;}}
11462 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11463 .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;}}
11464 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11465 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11466 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11467 .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;}}
11468 .export-btn:hover{{background:var(--line);}}
11469 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11470 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11471 .site-footer a{{color:var(--muted);}}
11472 .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;}}
11473 .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;}}
11474 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11475 /* Modal system (Retention Policy / Clean-up) */
11476 .tr-modal-backdrop{{display:none;position:fixed;inset:0;z-index:9000;background:rgba(40,24,12,0.34);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);align-items:center;justify-content:center;padding:24px;animation:tr-fade .16s ease;}}
11477 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11478 .tr-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 28px 70px rgba(40,24,12,0.32),0 4px 14px rgba(40,24,12,0.16);width:100%;max-height:92vh;overflow-y:auto;animation:tr-pop .18s cubic-bezier(.2,.9,.3,1.2);}}
11479 .tr-modal{{background:rgba(255,255,255,0.90);}}
11480 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11481 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11482 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11483 .tr-modal-icon{{flex:none;width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 4px 12px rgba(184,80,40,0.32);}}
11484 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11485 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11486 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11487 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11488 .tr-modal-body{{padding:22px 30px;}}
11489 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11490 .tr-btn{{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:10px;font-size:13.5px;font-weight:800;cursor:pointer;border:1px solid transparent;transition:transform .12s ease,box-shadow .12s ease,background .12s ease,opacity .12s ease;font-family:inherit;line-height:1;}}
11491 .tr-btn:hover{{transform:translateY(-1px);}}
11492 .tr-btn:active{{transform:translateY(0);}}
11493 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11494 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11495 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11496 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11497 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11498 .tr-btn-secondary:hover{{background:var(--line);}}
11499 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11500 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11501 </style>
11502</head>
11503<body>
11504 <div class="background-watermarks" aria-hidden="true">
11505 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11506 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11507 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11508 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11509 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11510 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11511 </div>
11512 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11513 <div class="top-nav">
11514 <div class="top-nav-inner">
11515 <a class="brand" href="/">
11516 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11517 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11518 </a>
11519 <div class="nav-right">
11520 <a class="nav-pill" href="/">Home</a>
11521 <div class="nav-dropdown">
11522 <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>
11523 <div class="nav-dropdown-menu">
11524 <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>
11525 </div>
11526 </div>
11527 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11528 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11529 <div class="nav-dropdown">
11530 <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>
11531 <div class="nav-dropdown-menu">
11532 <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>
11533 </div>
11534 </div>
11535 <div class="server-status-wrap" id="server-status-wrap">
11536 <div class="nav-pill server-online-pill" id="server-status-pill">
11537 <span class="status-dot" id="status-dot"></span>
11538 <span id="server-status-label">Server</span>
11539 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11540 </div>
11541 <div class="server-status-tip">
11542 OxideSLOC is running — accessible on your network.
11543 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11544 </div>
11545 </div>
11546 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11547 <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>
11548 </button>
11549 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11550 <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>
11551 <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>
11552 </button>
11553 </div>
11554 </div>
11555 </div>
11556
11557 <div class="page">
11558 {watched_dirs_html}
11559 <div class="summary-strip" id="trend-stats"></div>
11560 <div class="panel">
11561 <div class="trend-header">
11562 <div class="trend-title-block">
11563 <h1>Trend Reports</h1>
11564 <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root,<br>choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
11565 <span class="chart-hint-inline">
11566 <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>
11567 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
11568 </span>
11569 </div>
11570 <div class="chart-actions">
11571 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11572 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11573 Retention Policy
11574 </button>
11575 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11576 <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>
11577 Clean up old runs
11578 </button>
11579 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11580 <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>
11581 Export Excel
11582 </button>
11583 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11584 <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>
11585 Export PNG
11586 </button>
11587 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11588 <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
11589 Export PDF
11590 </button>
11591 </div>
11592 </div>
11593
11594 <div class="controls-centered">
11595 <label>Project Root:
11596 <select class="chart-select" id="root-sel">
11597 <option value="">All projects</option>
11598 </select>
11599 </label>
11600 <label>Y Metric:
11601 <select class="chart-select" id="y-sel">
11602 <option value="code_lines">Code Lines</option>
11603 <option value="comment_lines">Comment Lines</option>
11604 <option value="blank_lines">Blank Lines</option>
11605 <option value="physical_lines">Physical Lines</option>
11606 <option value="files_analyzed">Files Analyzed</option>
11607 </select>
11608 </label>
11609 <label>X Axis:
11610 <select class="chart-select" id="x-sel">
11611 <option value="time">By Time</option>
11612 <option value="commit" selected>By Commit</option>
11613 <option value="release">By Release</option>
11614 <option value="tag">Tagged Commits</option>
11615 </select>
11616 </label>
11617 <label id="submodule-label" style="display:none;">Submodule:
11618 <select class="chart-select" id="sub-sel">
11619 <option value="">All (project total)</option>
11620 </select>
11621 </label>
11622 <label>Chart Size:
11623 <select class="chart-select" id="scale-sel">
11624 <option value="0.75">Compact</option>
11625 <option value="1.2" selected>Normal</option>
11626 <option value="1.38">Large</option>
11627 </select>
11628 </label>
11629 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
11630 </div>
11631
11632 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
11633 <div id="data-table-wrap" style="overflow-x:auto;"></div>
11634 </div>
11635 </div>
11636
11637 <script nonce="{nonce}">
11638 (function() {{
11639 // Theme persistence
11640 var b = document.body;
11641 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11642 var tgl = document.getElementById('theme-toggle');
11643 if (tgl) tgl.addEventListener('click', function() {{
11644 var d = b.classList.toggle('dark-theme');
11645 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11646 }});
11647
11648 // Watermark randomizer
11649 (function() {{
11650 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11651 if (!wms.length) return;
11652 var placed = [];
11653 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;}}
11654 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];}}
11655 var half=Math.floor(wms.length/2);
11656 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;}});
11657 }})();
11658
11659 // Code particles
11660 (function() {{
11661 var container = document.getElementById('code-particles');
11662 if (!container) return;
11663 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'];
11664 for (var i = 0; i < 38; i++) {{
11665 (function(idx) {{
11666 var el = document.createElement('span');
11667 el.className = 'code-particle';
11668 el.textContent = snippets[idx % snippets.length];
11669 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11670 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11671 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11672 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';
11673 container.appendChild(el);
11674 }})(i);
11675 }}
11676 }})();
11677
11678 // Watched folder picker
11679 (function() {{
11680 var btn = document.getElementById('add-watched-btn');
11681 if (!btn) return;
11682 btn.addEventListener('click', function() {{
11683 fetch('/pick-directory?kind=reports')
11684 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11685 .then(function(data) {{
11686 if (!data.cancelled && data.selected_path) {{
11687 var form = document.createElement('form');
11688 form.method = 'POST';
11689 form.action = '/watched-dirs/add';
11690 var ri = document.createElement('input');
11691 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11692 var fi = document.createElement('input');
11693 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11694 form.appendChild(ri); form.appendChild(fi);
11695 document.body.appendChild(form);
11696 form.submit();
11697 }}
11698 }})
11699 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11700 }});
11701 }})();
11702
11703 // Settings / color-scheme modal
11704 (function() {{
11705 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'}}];
11706 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);}});}}
11707 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11708 var btn=document.getElementById('settings-btn');if(!btn)return;
11709 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11710 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>';
11711 document.body.appendChild(m);
11712 var g=document.getElementById('scheme-grid');
11713 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);}});
11714 var cl=document.getElementById('settings-close');
11715 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);
11716 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');}});
11717 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11718 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11719 }})();
11720 }})();
11721
11722 var ROOTS = {roots_json};
11723 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11724 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11725 var allData = [];
11726
11727 // Populate root selector
11728 var rootSel = document.getElementById('root-sel');
11729 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11730
11731 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();}}
11732 function fmtFull(n){{return Number(n).toLocaleString();}}
11733 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
11734
11735 // Tooltip
11736 var tt = document.createElement('div');
11737 tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:100000;max-width:280px;color:var(--text);';
11738 document.body.appendChild(tt);
11739 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11740 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';}}
11741 function hideTT(){{tt.style.display='none';}}
11742 window.addEventListener('blur',function(){{hideTT();}});
11743 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11744
11745 function statExact(compact, full){{
11746 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11747 }}
11748 function statVal(n){{
11749 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11750 }}
11751
11752 function updateStats(data){{
11753 var statsEl=document.getElementById('trend-stats');
11754 if(!statsEl)return;
11755 if(!data||!data.length){{statsEl.innerHTML='';return;}}
11756 var yKey=document.getElementById('y-sel').value;
11757 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11758 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11759 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11760 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11761 var absDelta=Math.abs(delta);
11762 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11763 var deltaExact=statExact(deltaCompact,deltaFull);
11764 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11765 statsEl.innerHTML=
11766 '<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>'+
11767 '<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>'+
11768 '<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>'+
11769 '<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>';
11770 }}
11771
11772 var subSel = document.getElementById('sub-sel');
11773 var subLabel = document.getElementById('submodule-label');
11774
11775 function populateSubmodules(root){{
11776 if(!subSel||!subLabel)return;
11777 while(subSel.options.length>1)subSel.remove(1);
11778 subSel.value='';
11779 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11780 fetch(url)
11781 .then(function(r){{return r.json();}})
11782 .then(function(subs){{
11783 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11784 subs.forEach(function(s){{
11785 var o=document.createElement('option');
11786 o.value=s.name;
11787 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11788 subSel.appendChild(o);
11789 }});
11790 subLabel.style.display='';
11791 }})
11792 .catch(function(){{subLabel.style.display='none';}});
11793 }}
11794
11795 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11796
11797 function loadAndRender(){{
11798 var root = rootSel.value;
11799 var sub = subSel ? subSel.value : '';
11800 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11801 document.getElementById('data-table-wrap').innerHTML='';
11802 var url = '/api/metrics/history?limit=100'
11803 + (root ? '&root='+encodeURIComponent(root) : '')
11804 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
11805 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11806 allData = data;
11807 render(data);
11808 updateStats(data);
11809 }}).catch(function(){{
11810 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>';
11811 }});
11812 }}
11813
11814 function render(data){{
11815 var yKey = document.getElementById('y-sel').value;
11816 var xMode = document.getElementById('x-sel').value;
11817
11818 // Filter for tag/release mode
11819 var pts = data;
11820 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11821
11822 // Sort oldest-first for the line chart
11823 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11824
11825 var wrap = document.getElementById('chart-wrap');
11826 if(!pts.length){{
11827 var emptyMsg = (xMode === 'tag')
11828 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11829 : 'No scan data found for the selected filters.';
11830 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11831 renderTable([]);
11832 return;
11833 }}
11834
11835 var scaleEl=document.getElementById('scale-sel');
11836 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
11837 renderTrendInto(wrap, pts, yKey, xMode, sc);
11838 renderTable(pts, yKey);
11839 }}
11840
11841 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
11842 // Shared by the inline chart and the Full View modal so both render identically.
11843 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
11844 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
11845 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
11846 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
11847 var W=Math.max(600,availW),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
11848 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
11849
11850 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11851
11852 var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
11853 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>';
11854
11855 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
11856
11857 // Grid + Y axis ticks
11858 for(var ti=0;ti<=5;ti++){{
11859 var gy=PT+CH-Math.round(ti/5*CH);
11860 var gv=Math.round(ti/5*maxY);
11861 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
11862 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
11863 }}
11864
11865 // X axis labels (every N-th point to avoid crowding)
11866 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
11867 pts.forEach(function(d,i){{
11868 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11869 if(i%labelEvery===0||i===pts.length-1){{
11870 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)));
11871 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>';
11872 }}
11873 }});
11874
11875 // Axis label
11876 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
11877 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>';
11878 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>';
11879
11880 // Area fill + line path
11881 var pathD='';
11882 pts.forEach(function(d,i){{
11883 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11884 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11885 pathD+=(i===0?'M':'L')+x+','+y;
11886 }});
11887 if(pts.length>1){{
11888 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
11889 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
11890 }}
11891 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
11892
11893 // Data points (clickable) + permanent value labels
11894 var showLabels = pts.length <= 40;
11895 var labelEveryN = pts.length > 20 ? 2 : 1;
11896 pts.forEach(function(d,i){{
11897 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11898 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11899 var hasTags=d.tags&&d.tags.length>0;
11900 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
11901 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
11902 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+'"/>';
11903 if(showLabels && i%labelEveryN===0){{
11904 var lx=x, ly=y-r-5;
11905 svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmtFull(Number(d[yKey]))+'</text>';
11906 }}
11907 }});
11908
11909 svg+='</svg>';
11910 wrap.innerHTML=svg;
11911
11912 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
11913 function lineYAt(mx){{
11914 var n=pts.length;
11915 if(n===0)return PT+CH;
11916 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
11917 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
11918 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
11919 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
11920 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
11921 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
11922 return y0+t*(y1-y0);
11923 }}
11924
11925 // SVG-level mousemove: show the value tooltip only when the pointer is over the
11926 // gradient fill (inside the chart and at/below the line) — never in the empty
11927 // space above the line. Cursor follows the same rule.
11928 (function(){{
11929 var svgEl=wrap.querySelector('svg');
11930 if(!svgEl)return;
11931 svgEl.addEventListener('mousemove',function(e){{
11932 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
11933 var rect=svgEl.getBoundingClientRect();
11934 var scaleX=W/Math.max(rect.width,1);
11935 var scaleY=H/Math.max(rect.height,1);
11936 var mouseX=(e.clientX-rect.left)*scaleX;
11937 var mouseY=(e.clientY-rect.top)*scaleY;
11938 var ly=lineYAt(mouseX);
11939 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
11940 svgEl.style.cursor='pointer';
11941 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
11942 var d=pts[idx];
11943 var val=Number(d[yKey]);
11944 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
11945 showTT(e,
11946 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
11947 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
11948 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
11949 );
11950 }});
11951 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
11952 }})();
11953
11954 // Attach point tooltips
11955 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
11956 c.addEventListener('mouseover',function(e){{
11957 var d=pts[parseInt(this.dataset.idx)];
11958 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(''):'';
11959 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>':'';
11960 showTT(e,
11961 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
11962 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
11963 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
11964 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
11965 );
11966 this.setAttribute('r','8');
11967 }});
11968 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
11969 c.addEventListener('mousemove',moveTT);
11970 c.addEventListener('click',function(){{
11971 var d=pts[parseInt(this.dataset.idx)];
11972 if(d.html_url) window.open(d.html_url,'_blank');
11973 }});
11974 }});
11975 }}
11976
11977 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
11978 var shProjFilter='', shBranchFilter='';
11979
11980 function fmtPST(isoStr){{
11981 if(!isoStr)return'';
11982 var d=new Date(isoStr);
11983 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
11984 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);}}
11985 function p(n){{return n<10?'0'+n:String(n);}}
11986 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++;}}}}
11987 var yr=d.getUTCFullYear();
11988 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
11989 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
11990 var isDST=d>=dstStart&&d<dstEnd;
11991 var off=isDST?-7*3600*1000:-8*3600*1000;
11992 var lbl=isDST?'PDT':'PST';
11993 var loc=new Date(d.getTime()+off);
11994 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
11995 }}
11996
11997 function getShRows(){{
11998 var proj=shProjFilter.toLowerCase().trim();
11999 var branch=shBranchFilter;
12000 return shData.filter(function(d){{
12001 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12002 if(branch&&(d.branch||'')!==branch)return false;
12003 return true;
12004 }});
12005 }}
12006
12007 function renderShPage(){{
12008 var filtered=getShRows();
12009 if(shSortCol){{
12010 filtered.sort(function(a,b){{
12011 var va,vb;
12012 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12013 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12014 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12015 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12016 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12017 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12018 }});
12019 }}
12020 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12021 shPage=Math.min(shPage,totalPages);
12022 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12023 var visible=filtered.slice(start,end);
12024 var tbody=document.getElementById('sh-tbody');
12025 if(!tbody)return;
12026 tbody.innerHTML=visible.map(function(d){{
12027 var tsHtml=esc(fmtPST(d.timestamp));
12028 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>';
12029 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>';
12030 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12031 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12032 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12033 var reportCell='';
12034 if(d.html_url){{
12035 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12036 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>';}}
12037 reportCell+='</div>';
12038 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12039 if(d.submodule_links&&d.submodule_links.length){{
12040 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12041 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12042 reportCell+='</div></details>';
12043 }}
12044 return '<tr>'
12045 +'<td>'+tsHtml+'</td>'
12046 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12047 +'<td>'+runIdHtml+'</td>'
12048 +'<td>'+commitHtml+'</td>'
12049 +'<td>'+branchHtml+'</td>'
12050 +'<td>'+tags+'</td>'
12051 +'<td class="num">'+metricHtml+'</td>'
12052 +'<td class="report-cell">'+reportCell+'</td>'
12053 +'</tr>';
12054 }}).join('');
12055 var pgRange=document.getElementById('sh-pg-range');
12056 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12057 var pgInfo=document.getElementById('sh-pg-info');
12058 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12059 var pgBtns=document.getElementById('sh-pg-btns');
12060 if(pgBtns){{
12061 pgBtns.innerHTML='';
12062 function mkPgBtn(lbl,pg,active,disabled){{
12063 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12064 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12065 return b;
12066 }}
12067 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12068 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12069 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12070 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12071 }}
12072 }}
12073
12074 function wireTableBehavior(){{
12075 var pf=document.getElementById('sh-proj-filter');
12076 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12077 var bf=document.getElementById('sh-branch-filter');
12078 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12079 var rb=document.getElementById('sh-reset-btn');
12080 if(rb)rb.addEventListener('click',function(){{
12081 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12082 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12083 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12084 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');}});
12085 renderShPage();
12086 }});
12087 var pps=document.getElementById('sh-per-page');
12088 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12089 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12090 ths.forEach(function(th){{
12091 th.addEventListener('click',function(e){{
12092 if(e.target.classList.contains('col-resize-handle'))return;
12093 var col=th.dataset.col;
12094 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12095 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12096 th.classList.add('sort-'+shSortOrder);
12097 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12098 shPage=1;renderShPage();
12099 }});
12100 }});
12101 var table=document.getElementById('scan-history-table');
12102 if(!table)return;
12103 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12104 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12105 allThs.forEach(function(th,i){{
12106 var handle=th.querySelector('.col-resize-handle');
12107 if(!handle||!cols[i])return;
12108 var startX,startW;
12109 handle.addEventListener('mousedown',function(e){{
12110 e.stopPropagation();e.preventDefault();
12111 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12112 handle.classList.add('dragging');
12113 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12114 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12115 document.addEventListener('mousemove',onMove);
12116 document.addEventListener('mouseup',onUp);
12117 }});
12118 }});
12119 }}
12120
12121 function renderTable(pts, yKey){{
12122 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12123 var wrap=document.getElementById('data-table-wrap');
12124 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12125 var yLabel=Y_LABELS[yKey]||yKey||'';
12126 shData=pts.slice().reverse();
12127 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12128 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12129 var branches={{}};
12130 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12131 var branchOpts='<option value="">All branches</option>';
12132 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12133 wrap.innerHTML=
12134 '<div class="chart-section-header">SCAN HISTORY</div>'+
12135 '<div class="filter-row">'+
12136 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12137 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12138 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12139 '</div>'+
12140 '<div class="table-wrap">'+
12141 '<table id="scan-history-table" class="data-table">'+
12142 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12143 '<thead><tr id="sh-thead">'+
12144 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12145 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12146 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12147 '<th>Commit<div class="col-resize-handle"></div></th>'+
12148 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12149 '<th>Tags<div class="col-resize-handle"></div></th>'+
12150 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12151 '<th>Report<div class="col-resize-handle"></div></th>'+
12152 '</tr></thead>'+
12153 '<tbody id="sh-tbody"></tbody>'+
12154 '</table>'+
12155 '</div>'+
12156 '<div class="pagination">'+
12157 '<span class="pagination-info" id="sh-pg-info"></span>'+
12158 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12159 '<div style="display:flex;align-items:center;gap:8px;">'+
12160 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12161 '<select class="filter-select" id="sh-per-page">'+
12162 '<option value="10">10 per page</option>'+
12163 '<option value="25" selected>25 per page</option>'+
12164 '<option value="50">50 per page</option>'+
12165 '<option value="100">100 per page</option>'+
12166 '</select>'+
12167 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12168 '</div>'+
12169 '</div>';
12170 wireTableBehavior();
12171 renderShPage();
12172 }}
12173
12174 function exportXLSX(){{
12175 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12176 var xbtn=document.getElementById('export-xlsx-btn');
12177 var xorig=xbtn?xbtn.innerHTML:'';
12178 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12179 var root=rootSel.value;
12180 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12181 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12182 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12183 buildAndDownloadXLSX(cm);
12184 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12185 }}
12186
12187 function buildAndDownloadXLSX(churnMap){{
12188 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12189 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12190 // (sorted is newest-first), so a given project/commit appears at most once.
12191 var seenPC={{}},dedup=[];
12192 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12193 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12194 var s1R=dedup.map(function(d){{
12195 var c=churnMap[d.run_id]||{{}};
12196 return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0];
12197 }});
12198 var pm={{}};
12199 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12200 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'];
12201 var s2R=Object.keys(pm).map(function(p){{
12202 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12203 var lat=sc[sc.length-1],fst=sc[0];
12204 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12205 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);
12206 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];
12207 }});
12208 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12209 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12210 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12211 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12212 }}
12213
12214 function buildXLSX(sheets,chartRows,chartRows2){{
12215 function s2b(s){{return new TextEncoder().encode(s);}}
12216 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12217 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;}}
12218 function crc32(d){{
12219 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;}}}}
12220 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12221 }}
12222 function buildSheet(hdr,rows,drawRid,withCtrl){{
12223 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12224 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12225 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12226 x+='<row r="1">';
12227 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12228 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12229 x+='</row>';
12230 rows.forEach(function(row,ri){{
12231 var rn=ri+2;
12232 x+='<row r="'+rn+'">';
12233 row.forEach(function(cell,ci){{
12234 var addr=col2l(ci+1)+rn;
12235 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12236 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12237 }});
12238 if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12239 x+='</row>';
12240 }});
12241 x+='</sheetData>';
12242 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12243 return x+'</worksheet>';
12244 }}
12245 function buildChartXML(rows){{
12246 var sn="'Scan History'";
12247 var nr=rows.length,er=nr+1;
12248 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'}}];
12249 var catCol='C',catIdx=2;
12250 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12251 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">';
12252 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12253 x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Scan History \u2014 all metrics over time</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12254 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12255 sd.forEach(function(s,i){{
12256 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12257 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>';
12258 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12259 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>';
12260 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12261 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12262 x+='</c:strCache></c:strRef></c:cat>';
12263 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+'"/>';
12264 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12265 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12266 }});
12267 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12268 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>';
12269 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>';
12270 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12271 return x;
12272 }}
12273 function buildChartXML2(rows){{
12274 var sn="'By Project'";
12275 var nr=rows.length,er=nr+1;
12276 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'}}];
12277 var catCol='A',catIdx=0;
12278 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12279 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">';
12280 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12281 x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Latest metrics by project</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12282 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12283 sd.forEach(function(s,i){{
12284 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12285 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>';
12286 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12287 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>';
12288 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12289 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12290 x+='</c:strCache></c:strRef></c:cat>';
12291 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+'"/>';
12292 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12293 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12294 }});
12295 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12296 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>';
12297 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>';
12298 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12299 return x;
12300 }}
12301 function buildChartXML3(rows){{
12302 var sn="'Scan History'";
12303 var nr=rows.length,er=nr+1;
12304 var catCol='C',catIdx=2;
12305 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12306 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">';
12307 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12308 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12309 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12310 x+="<c:tx><c:strRef><c:f>'Focus Chart'!$B$1</c:f><c:strCache><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>";
12311 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12312 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>';
12313 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>';
12314 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12315 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12316 x+='</c:strCache></c:strRef></c:cat>';
12317 x+='<c:val><c:numRef><c:f>'+sn+'!$Q$2:$Q$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12318 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12319 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12320 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12321 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>';
12322 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>';
12323 x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Single-Metric Focus</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12324 return x;
12325 }}
12326 function buildFocusSheet(drawRid){{
12327 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12328 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12329 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12330 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12331 x+='<sheetData><row r="1">';
12332 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12333 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12334 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
12335 x+='</row></sheetData>';
12336 x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified"</formula1></dataValidation></dataValidations>';
12337 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12338 return x+'</worksheet>';
12339 }}
12340 var hasChart=!!(chartRows&&chartRows.length);
12341 var nr=hasChart?chartRows.length:0;
12342 var hasChart2=!!(chartRows2&&chartRows2.length);
12343 var nr2=hasChart2?chartRows2.length:0;
12344 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>';
12345 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"/>';
12346 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12347 if(hasChart){{ct+='<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/charts/chart3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/drawings/drawing3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12348 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"/>';}}
12349 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12350 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>';
12351 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12352 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"/>';}});
12353 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12354 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>';
12355 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12356 wbx+='</sheets></workbook>';
12357 var files=[
12358 {{name:'[Content_Types].xml',data:s2b(ct)}},
12359 {{name:'_rels/.rels',data:s2b(dotrels)}},
12360 {{name:'xl/workbook.xml',data:s2b(wbx)}},
12361 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12362 {{name:'xl/styles.xml',data:s2b(styl)}}
12363 ];
12364 // Chart embedded directly in Scan History (sheet1); By Project is plain
12365 sheets.forEach(function(s,i){{
12366 var sx;
12367 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12368 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12369 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12370 }});
12371 if(hasChart){{
12372 var fromRow=nr+4,toRow=nr+34;
12373 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>')}});
12374 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12375 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">';
12376 drx+='<xdr:twoCellAnchor editAs="twoCell">';
12377 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>';
12378 drx+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12379 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12380 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12381 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12382 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12383 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12384 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12385 files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/></Relationships>')}});
12386 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12387 files.push({{name:'xl/worksheets/_rels/sheet3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing3.xml"/></Relationships>')}});
12388 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12389 drx3+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12390 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12391 drx3+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12392 drx3+='<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>31</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12393 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12394 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12395 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12396 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12397 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12398 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12399 files.push({{name:'xl/drawings/_rels/drawing3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
12400 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12401 }}
12402 if(hasChart2){{
12403 var fromRow2=nr2+4,toRow2=nr2+36;
12404 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>')}});
12405 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12406 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">';
12407 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12408 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>';
12409 drx2+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12410 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12411 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12412 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12413 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12414 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12415 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12416 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>')}});
12417 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12418 }}
12419 var parts=[],offsets=[],total=0;
12420 files.forEach(function(f){{
12421 offsets.push(total);
12422 var nb=s2b(f.name),crc=crc32(f.data);
12423 var h=new DataView(new ArrayBuffer(30+nb.length));
12424 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12425 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12426 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12427 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12428 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12429 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12430 total+=30+nb.length+f.data.length;
12431 }});
12432 var cdStart=total;
12433 files.forEach(function(f,fi){{
12434 var nb=s2b(f.name),crc=crc32(f.data);
12435 var cd=new DataView(new ArrayBuffer(46+nb.length));
12436 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12437 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12438 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12439 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12440 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12441 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12442 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12443 }});
12444 var cdSz=total-cdStart;
12445 var eocd=new DataView(new ArrayBuffer(22));
12446 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12447 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12448 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12449 parts.push(new Uint8Array(eocd.buffer));
12450 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12451 var out=new Uint8Array(sz);var off=0;
12452 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12453 return out.buffer;
12454 }}
12455
12456 function trendTitleParts(){{
12457 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12458 var subSelEl=document.getElementById('sub-sel');
12459 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12460 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12461 var proj=(document.getElementById('root-sel').value)||'All projects';
12462 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12463 var cnt=(allData&&allData.length)||0;
12464 var now=new Date();
12465 function p2(n){{return(n<10?'0':'')+n;}}
12466 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12467 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
12468 }}
12469
12470 function exportPNG(){{
12471 var svgEl=document.querySelector('#chart-wrap svg');
12472 if(!svgEl){{alert('No chart to export yet.');return;}}
12473 var svgStr=new XMLSerializer().serializeToString(svgEl);
12474 var vb=svgEl.viewBox.baseVal,scale=2;
12475 var headerH=84,footerH=36;
12476 var lw=(vb.width||900),lh=(vb.height||380);
12477 var w=lw*scale,h=(lh+headerH+footerH)*scale;
12478 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12479 var url=URL.createObjectURL(blob);
12480 var img=new Image();
12481 var tp=trendTitleParts();
12482 img.onload=function(){{
12483 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12484 var ctx=canvas.getContext('2d');
12485 var cs=getComputedStyle(document.body);
12486 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12487 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12488 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12489 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12490 ctx.scale(scale,scale);
12491 ctx.textBaseline='alphabetic';ctx.textAlign='left';
12492 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12493 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12494 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12495 ctx.strokeStyle=oxide;ctx.globalAlpha=0.55;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,74);ctx.lineTo(lw-24,74);ctx.stroke();ctx.globalAlpha=1;
12496 ctx.drawImage(img,0,headerH);
12497 var fy=headerH+lh;
12498 ctx.strokeStyle=oxide;ctx.globalAlpha=0.4;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(24,fy+9);ctx.lineTo(lw-24,fy+9);ctx.stroke();ctx.globalAlpha=1;
12499 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12500 ctx.fillText('\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc',lw/2,fy+27);
12501 ctx.textAlign='left';
12502 URL.revokeObjectURL(url);
12503 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12504 }};
12505 img.src=url;
12506 }}
12507
12508 function exportPDF(){{
12509 var svgEl=document.querySelector('#chart-wrap svg');
12510 if(!svgEl){{alert('No chart to export yet.');return;}}
12511 var tp=trendTitleParts();
12512 var svgStr=new XMLSerializer().serializeToString(svgEl);
12513 var statsEl=document.getElementById('trend-stats');
12514 var statsHtml=statsEl?statsEl.innerHTML:'';
12515 var yK=document.getElementById('y-sel').value;
12516 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12517 var yL=yLabels[yK]||yK;
12518 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12519 var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
12520 rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
12521 tableHtml+='</tbody></table>';
12522 var css='<style>'
12523 +'*{{box-sizing:border-box;}}'
12524 +'html,body{{margin:0;padding:0;}}'
12525 // Masthead/footer flow in document order — a position:fixed header repeats
12526 // on every printed page in Chromium and hides the rows beneath it on pages
12527 // 2+. The trend table's <thead> repeats per page natively instead.
12528 +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
12529 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12530 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12531 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12532 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12533 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12534 +'.rep-body{{padding:22px 34px 0;}}'
12535 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12536 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12537 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12538 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12539 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12540 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12541 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12542 +'.stat-chip-tip{{display:none!important;}}'
12543 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12544 +'.stat-chip-label{{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#7b675b;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12545 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12546 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12547 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12548 +'.rep-chart svg{{max-width:100%;height:auto;}}'
12549 +'.chart-section-header{{background:#191c26;color:#fff;padding:7px 13px;border-radius:4px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin:18px 0 10px;}}'
12550 +'.filter-row{{display:none!important;}}'
12551 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12552 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12553 +'th{{background:#f0e9e0;font-weight:800;}}'
12554 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12555 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12556 +'.rep-foot{{margin-top:22px;background:#191c26;color:rgba(255,255,255,0.72);padding:9px 34px;font-size:11px;font-weight:600;text-align:center;line-height:1.5;}}'
12557 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12558 +'</style>';
12559 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12560 +'<div class="rep-masthead"><div class="rep-mast-left"><span class="rep-mast-brand">oxide-sloc</span><span class="rep-mast-sub">Code Metrics Report \u00b7 Trend</span></div><div class="rep-mast-ts">Generated '+tp.date+'</div></div>'
12561 +'<div class="rep-body">'
12562 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12563 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12564 +'<div class="summary-strip">'+statsHtml+'</div>'
12565 +'<div class="rep-chart">'+svgStr+'</div>'
12566 +tableHtml
12567 +'</div>'
12568 +'<div class="rep-foot"><div>\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 local code metrics workbench \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc</div><div class="rep-foot-gen">Generated '+tp.date+'</div></div>'
12569 +'</body></html>';
12570 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12571 }}
12572
12573 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12574 var el=document.getElementById(id);
12575 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12576 }});
12577 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12578 // tracks the container like the responsive Chart.js charts do.
12579 var _rsT=null;
12580 window.addEventListener('resize',function(){{
12581 if(_rsT)clearTimeout(_rsT);
12582 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12583 }});
12584 rootSel.addEventListener('change',function(){{
12585 populateSubmodules(rootSel.value);
12586 loadAndRender();
12587 }});
12588 if(subSel)subSel.addEventListener('change',loadAndRender);
12589
12590 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12591 (function(){{
12592 var fvBtn=document.getElementById('tr-chart-fv-btn');
12593 if(!fvBtn)return;
12594 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12595 fvBtn.addEventListener('click',function(){{
12596 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12597 var yKey=document.getElementById('y-sel').value;
12598 var xMode=document.getElementById('x-sel').value;
12599 var pts=allData;
12600 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12601 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12602 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12603 var tp=trendTitleParts();
12604 var ov=document.createElement('div');
12605 ov.className='tr-chart-full-modal';
12606 ov.innerHTML='<div class="tr-chart-full-inner">'
12607 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12608 +'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'
12609 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12610 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12611 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12612 document.body.appendChild(ov);
12613 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12614 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12615 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12616 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12617 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12618 }});
12619 }})();
12620
12621 var xlsxBtn=document.getElementById('export-xlsx-btn');
12622 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12623 var pngBtn=document.getElementById('export-png-btn');
12624 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12625 var pdfBtn=document.getElementById('export-pdf-btn');
12626 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12627
12628 // ── Clean-up modal ───────────────────────────────────────────────────────
12629 (function(){{
12630 var triggerBtn=document.getElementById('cleanup-runs-btn');
12631 if(!triggerBtn)return;
12632 var modal=document.createElement('div');
12633 modal.className='tr-modal-backdrop';
12634 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12635 +'<div class="tr-modal-head">'
12636 +'<div class="tr-modal-icon danger"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg></div>'
12637 +'<div><h2 class="tr-modal-title">Clean up old runs</h2><p class="tr-modal-sub">One-shot deletion of older scan artifacts</p></div>'
12638 +'</div>'
12639 +'<div class="tr-modal-body">'
12640 +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
12641 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12642 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12643 +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
12644 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12645 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12646 +'</div>'
12647 +'<div class="tr-modal-foot">'
12648 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12649 +'<button class="tr-btn tr-btn-danger" id="cleanup-confirm-btn" type="button"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete old runs</button>'
12650 +'</div></div>';
12651 document.body.appendChild(modal);
12652 triggerBtn.addEventListener('click',function(){{
12653 document.getElementById('cleanup-status').style.display='none';
12654 modal.style.display='flex';
12655 }});
12656 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12657 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12658 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12659 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12660 var confirmBtn=this;
12661 confirmBtn.disabled=true;
12662 var status=document.getElementById('cleanup-status');
12663 status.style.display='block';
12664 status.style.background='#dbeafe';status.style.color='#1e40af';
12665 status.textContent='Deleting\u2026';
12666 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12667 .then(function(resp){{
12668 return resp.json().then(function(d){{
12669 if(resp.ok){{
12670 status.style.background='#dcfce7';status.style.color='#166534';
12671 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12672 setTimeout(function(){{window.location.reload();}},1500);
12673 }}else{{
12674 status.style.background='#fee2e2';status.style.color='#991b1b';
12675 status.textContent='Error: '+(d.error||'Unexpected error');
12676 confirmBtn.disabled=false;
12677 }}
12678 }});
12679 }})
12680 .catch(function(e){{
12681 status.style.background='#fee2e2';status.style.color='#991b1b';
12682 status.textContent='Network error: '+String(e);
12683 confirmBtn.disabled=false;
12684 }});
12685 }});
12686 }})();
12687
12688 // ── Retention policy panel ────────────────────────────────────────────────
12689 (function(){{
12690 var triggerBtn=document.getElementById('retention-policy-btn');
12691 if(!triggerBtn)return;
12692 var modal=document.createElement('div');
12693 modal.className='tr-modal-backdrop';
12694 modal.style.zIndex='9001';
12695 modal.innerHTML=''
12696 +'<div class="tr-modal" style="max-width:640px;">'
12697 +'<div class="tr-modal-head">'
12698 +'<div class="tr-modal-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg></div>'
12699 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12700 +'</div>'
12701 +'<div class="tr-modal-body">'
12702 +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
12703 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12704 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12705 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12706 +'</div>'
12707 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12708 +'<div>'
12709 +'<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>'
12710 +'<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;">'
12711 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12712 +'</div>'
12713 +'<div>'
12714 +'<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>'
12715 +'<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;">'
12716 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12717 +'</div>'
12718 +'</div>'
12719 +'<div style="margin-bottom:20px;">'
12720 +'<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>'
12721 +'<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;">'
12722 +'<option value="1">Every hour</option>'
12723 +'<option value="6">Every 6 hours</option>'
12724 +'<option value="12">Every 12 hours</option>'
12725 +'<option value="24" selected>Every 24 hours</option>'
12726 +'<option value="48">Every 2 days</option>'
12727 +'<option value="72">Every 3 days</option>'
12728 +'<option value="168">Every week</option>'
12729 +'</select>'
12730 +'</div>'
12731 +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
12732 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12733 +'</div>'
12734 +'<div class="tr-modal-foot">'
12735 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12736 +'<button class="tr-btn tr-btn-secondary" id="rp-run-now-btn" type="button"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run Now</button>'
12737 +'<button class="tr-btn tr-btn-primary" id="rp-save-btn" type="button"><svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save Policy</button>'
12738 +'</div>'
12739 +'</div>';
12740 document.body.appendChild(modal);
12741
12742 function rpShowStatus(msg,ok){{
12743 var s=document.getElementById('rp-status');
12744 s.style.display='block';
12745 s.style.background=ok?'#dcfce7':'#fee2e2';
12746 s.style.color=ok?'#166534':'#991b1b';
12747 s.textContent=msg;
12748 }}
12749 function fmtAgo(iso){{
12750 if(!iso)return'Never';
12751 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12752 if(diff<60)return diff+'s ago';
12753 if(diff<3600)return Math.floor(diff/60)+'m ago';
12754 if(diff<86400)return Math.floor(diff/3600)+'h ago';
12755 return Math.floor(diff/86400)+'d ago';
12756 }}
12757 function loadPolicy(){{
12758 fetch('/api/cleanup-policy')
12759 .then(function(r){{return r.json();}})
12760 .then(function(d){{
12761 var p=d.policy;
12762 document.getElementById('rp-enabled').checked=p?p.enabled:false;
12763 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12764 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12765 var sel=document.getElementById('rp-interval');
12766 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;}}}}}}
12767 var lr=document.getElementById('rp-last-run');
12768 if(d.last_run_at){{
12769 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'):'');
12770 }}else{{
12771 lr.textContent='Auto-cleanup has not run yet.';
12772 }}
12773 }})
12774 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12775 }}
12776
12777 triggerBtn.addEventListener('click',function(){{
12778 document.getElementById('rp-status').style.display='none';
12779 loadPolicy();
12780 modal.style.display='flex';
12781 }});
12782 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12783 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12784
12785 document.getElementById('rp-save-btn').addEventListener('click',function(){{
12786 var enabled=document.getElementById('rp-enabled').checked;
12787 var ageVal=document.getElementById('rp-max-age').value.trim();
12788 var countVal=document.getElementById('rp-max-count').value.trim();
12789 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12790 if(enabled&&!ageVal&&!countVal){{
12791 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12792 return;
12793 }}
12794 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12795 var saveBtn=document.getElementById('rp-save-btn');
12796 saveBtn.disabled=true;
12797 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12798 .then(function(r){{
12799 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12800 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12801 }})
12802 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12803 .finally(function(){{saveBtn.disabled=false;}});
12804 }});
12805
12806 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12807 var btn=this;
12808 var orig=btn.innerHTML;
12809 btn.disabled=true;
12810 btn.textContent='Running\u2026';
12811 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12812 .then(function(r){{return r.json();}})
12813 .then(function(d){{
12814 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12815 loadPolicy();
12816 }})
12817 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12818 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12819 }});
12820 }})();
12821
12822 populateSubmodules(rootSel.value);
12823 loadAndRender();
12824
12825 (function randomizeWatermarks() {{
12826 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12827 if (!wms.length) return;
12828 var placed = [];
12829 function tooClose(top, left) {{
12830 for (var i = 0; i < placed.length; i++) {{
12831 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12832 if (dt < 16 && dl < 12) return true;
12833 }}
12834 return false;
12835 }}
12836 function pick(leftBand) {{
12837 for (var attempt = 0; attempt < 50; attempt++) {{
12838 var top = Math.random() * 88 + 2;
12839 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12840 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
12841 }}
12842 var top = Math.random() * 88 + 2;
12843 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12844 placed.push([top, left]); return [top, left];
12845 }}
12846 var half = Math.floor(wms.length / 2);
12847 wms.forEach(function (img, i) {{
12848 var pos = pick(i < half);
12849 var size = Math.floor(Math.random() * 100 + 120);
12850 var rot = (Math.random() * 360).toFixed(1);
12851 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12852 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;
12853 }});
12854 }})();
12855 (function spawnCodeParticles() {{
12856 var container = document.getElementById('code-particles');
12857 if (!container) return;
12858 var snippets = [
12859 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12860 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12861 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12862 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12863 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
12864 ];
12865 var count = 38;
12866 for (var i = 0; i < count; i++) {{
12867 (function(idx) {{
12868 var el = document.createElement('span');
12869 el.className = 'code-particle';
12870 el.textContent = snippets[idx % snippets.length];
12871 var left = Math.random() * 94 + 2;
12872 var top = Math.random() * 88 + 6;
12873 var dur = (Math.random() * 10 + 9).toFixed(1);
12874 var delay = (Math.random() * 18).toFixed(1);
12875 var rot = (Math.random() * 26 - 13).toFixed(1);
12876 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12877 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
12878 container.appendChild(el);
12879 }})(i);
12880 }}
12881 }})();
12882 </script>
12883 <footer class="site-footer">
12884 local code analysis - metrics, history and reports
12885 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
12886 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12887 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12888 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12889 · <a href="/api-docs" rel="noopener">REST API</a>
12890 </footer>
12891 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
12892 {toast_assets}
12893</body>
12894</html>"##,
12895 );
12896
12897 Html(html).into_response()
12898}
12899
12900fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12901 use std::collections::HashMap;
12902 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
12903 return vec![];
12904 }
12905 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
12906 for rec in per_file_records {
12907 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
12908 let e = totals.entry(lang.display_name().to_string()).or_default();
12909 e.0 += u64::from(cov.lines_found);
12910 e.1 += u64::from(cov.lines_hit);
12911 }
12912 }
12913 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
12915 .into_iter()
12916 .filter(|(_, (found, _))| *found > 0)
12917 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
12918 .collect();
12919 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
12920 pairs
12921 .iter()
12922 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
12923 .collect()
12924}
12925
12926fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
12927 let mut high = 0u64;
12928 let mut mid = 0u64;
12929 let mut low = 0u64;
12930 for rec in per_file_records {
12931 if let Some(cov) = &rec.coverage {
12932 if cov.lines_found == 0 {
12933 continue;
12934 }
12935 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
12936 if pct >= 80.0 {
12937 high += 1;
12938 } else if pct >= 50.0 {
12939 mid += 1;
12940 } else {
12941 low += 1;
12942 }
12943 }
12944 }
12945 (high, mid, low)
12946}
12947
12948fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12949 let mut arr: Vec<serde_json::Value> = per_file_records
12950 .iter()
12951 .filter_map(|rec| {
12952 rec.coverage.as_ref().map(|cov| {
12953 let line_pct = if cov.lines_found > 0 {
12954 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
12955 / 10.0
12956 } else {
12957 0.0
12958 };
12959 let fn_pct = if cov.functions_found > 0 {
12960 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
12961 .round()
12962 / 10.0
12963 } else {
12964 -1.0
12965 };
12966 serde_json::json!({
12967 "rel": rec.relative_path,
12968 "lang": rec.language.map_or("?", |l| l.display_name()),
12969 "line_pct": line_pct,
12970 "fn_pct": fn_pct,
12971 "lhit": cov.lines_hit,
12972 "lfound": cov.lines_found,
12973 "fhit": cov.functions_hit,
12974 "ffound": cov.functions_found,
12975 })
12976 })
12977 })
12978 .collect();
12979 arr.sort_by(|a, b| {
12980 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
12981 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
12982 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
12983 });
12984 arr
12985}
12986
12987#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
12989 let mut langs: Vec<&sloc_core::LanguageSummary> = run
12990 .totals_by_language
12991 .iter()
12992 .filter(|l| l.test_count > 0)
12993 .collect();
12994 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
12995 let lang_tests: Vec<serde_json::Value> = langs
12996 .iter()
12997 .map(|l| {
12998 let d = if l.code_lines > 0 {
12999 l.test_count as f64 / l.code_lines as f64 * 1000.0
13000 } else {
13001 0.0
13002 };
13003 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13004 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13005 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13006 })
13007 .collect();
13008 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13009 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13010 let t = &run.summary_totals;
13011 let total_tests = t.test_count;
13012 let density = if t.code_lines > 0 {
13013 total_tests as f64 / t.code_lines as f64 * 1000.0
13014 } else {
13015 0.0
13016 };
13017 let most_tested = langs.first().map_or_else(
13018 || "\u{2014}".to_string(),
13019 |l| l.language.display_name().to_string(),
13020 );
13021 let test_files: u64 = run
13022 .per_file_records
13023 .iter()
13024 .filter(|f| f.raw_line_categories.test_count > 0)
13025 .count() as u64;
13026 let cov_line = if t.coverage_lines_found > 0 {
13027 format!(
13028 "{:.1}",
13029 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13030 )
13031 } else {
13032 "0".to_string()
13033 };
13034 let cov_fn = if t.coverage_functions_found > 0 {
13035 format!(
13036 "{:.1}",
13037 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13038 )
13039 } else {
13040 "0".to_string()
13041 };
13042 let cov_branch = if t.coverage_branches_found > 0 {
13043 format!(
13044 "{:.1}",
13045 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13046 )
13047 } else {
13048 "0".to_string()
13049 };
13050 let has_cov = !cov_arr.is_empty();
13051 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13052 serde_json::json!({
13053 "totals": {
13054 "test_count": total_tests,
13055 "assertions": t.test_assertion_count,
13056 "suites": t.test_suite_count,
13057 "test_files": test_files,
13058 "total_files": t.files_analyzed,
13059 "density_str": format!("{density:.1}"),
13060 "most_tested": most_tested,
13061 "langs_with_tests": langs.len(),
13062 "cov_line": cov_line,
13063 "cov_fn": cov_fn,
13064 "cov_branch": cov_branch,
13065 },
13066 "lang_tests": lang_tests,
13067 "cov": cov_arr,
13068 "cov_tiers": {"high": high, "mid": mid, "low": low},
13069 "file_cov": file_cov_arr,
13070 "has_coverage": has_cov,
13071 "submodules": {},
13072 })
13073}
13074
13075#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13077 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13078 .language_summaries
13079 .iter()
13080 .filter(|l| l.test_count > 0)
13081 .collect();
13082 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13083 let lang_tests: Vec<serde_json::Value> = langs
13084 .iter()
13085 .map(|l| {
13086 let d = if l.code_lines > 0 {
13087 l.test_count as f64 / l.code_lines as f64 * 1000.0
13088 } else {
13089 0.0
13090 };
13091 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13092 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13093 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13094 })
13095 .collect();
13096 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13097 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13098 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13099 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13100 let density = if sub.code_lines > 0 {
13101 total_tests as f64 / sub.code_lines as f64 * 1000.0
13102 } else {
13103 0.0
13104 };
13105 let most_tested = langs.first().map_or_else(
13106 || "\u{2014}".to_string(),
13107 |l| l.language.display_name().to_string(),
13108 );
13109 serde_json::json!({
13110 "totals": {
13111 "test_count": total_tests,
13112 "assertions": total_assertions,
13113 "suites": total_suites,
13114 "test_files": test_files_approx,
13115 "total_files": sub.files_analyzed,
13116 "density_str": format!("{density:.1}"),
13117 "most_tested": most_tested,
13118 "langs_with_tests": langs.len(),
13119 "cov_line": "0",
13120 "cov_fn": "0",
13121 "cov_branch": "0",
13122 },
13123 "lang_tests": lang_tests,
13124 "cov": [],
13125 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13126 "has_coverage": false,
13127 })
13128}
13129
13130fn compute_cov_json_str(run: &AnalysisRun) -> String {
13131 use std::collections::HashMap;
13132 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13133 for rec in &run.per_file_records {
13134 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13135 let e = totals.entry(lang.display_name().to_string()).or_default();
13136 e.0 += u64::from(cov.lines_found);
13137 e.1 += u64::from(cov.lines_hit);
13138 }
13139 }
13140 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13142 .into_iter()
13143 .filter(|(_, (found, _))| *found > 0)
13144 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13145 .collect();
13146 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13147 let parts: Vec<String> = pairs
13148 .iter()
13149 .map(|(lang, pct)| {
13150 let name = lang.replace('"', "\\\"");
13151 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13152 })
13153 .collect();
13154 format!("[{}]", parts.join(","))
13155}
13156
13157fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13158 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13159 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13160}
13161
13162fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13163 let mut entry = build_test_scope_entry(run);
13164 if !run.submodule_summaries.is_empty() {
13165 let subs: serde_json::Map<String, serde_json::Value> = run
13166 .submodule_summaries
13167 .iter()
13168 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13169 .collect();
13170 entry["submodules"] = serde_json::Value::Object(subs);
13171 }
13172 entry
13173}
13174
13175fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13176 let name = l.language.display_name().replace('"', "\\\"");
13177 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13179 l.test_count as f64 / l.code_lines as f64 * 1000.0
13180 } else {
13181 0.0
13182 };
13183 format!(
13184 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13185 name = name,
13186 t = l.test_count,
13187 a = l.test_assertion_count,
13188 s = l.test_suite_count,
13189 c = l.code_lines,
13190 d = density,
13191 f = l.files,
13192 )
13193}
13194
13195fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13196 let Some(r) = run else {
13197 return "[]".to_string();
13198 };
13199 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13200 .totals_by_language
13201 .iter()
13202 .filter(|l| l.test_count > 0)
13203 .collect();
13204 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13205 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13206 format!("[{}]", parts.join(","))
13207}
13208
13209async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13211 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13212 scope_map.insert(
13213 "__all__".to_string(),
13214 latest_run.map_or_else(
13215 || {
13216 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13217 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13218 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13219 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13220 "has_coverage":false,"submodules":{}})
13221 },
13222 build_test_scope_entry,
13223 ),
13224 );
13225 let all_roots: Vec<String> = {
13226 let reg = state.registry.lock().await;
13227 let mut seen = std::collections::BTreeSet::new();
13228 reg.entries
13229 .iter()
13230 .flat_map(|e| e.input_roots.iter().cloned())
13231 .filter(|r| seen.insert(r.clone()))
13232 .collect()
13233 };
13234 for root in &all_roots {
13235 let json_path = {
13236 let reg = state.registry.lock().await;
13237 reg.entries
13238 .iter()
13239 .find(|e| e.input_roots.iter().any(|r| r == root))
13240 .and_then(|e| e.json_path.clone())
13241 };
13242 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13243 let json_str = tokio::fs::read_to_string(&p).await.ok();
13244 json_str
13245 .as_deref()
13246 .and_then(|s| serde_json::from_str(s).ok())
13247 } else {
13248 None
13249 };
13250 if let Some(ref run) = run_for_root {
13251 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13252 }
13253 }
13254 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13255}
13256
13257#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13261 State(state): State<AppState>,
13262 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13263) -> Response {
13264 auto_scan_watched_dirs(&state).await;
13265 let watched_dirs_list: Vec<String> = {
13266 let wd = state.watched_dirs.lock().await;
13267 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13268 };
13269 let latest_run: Option<AnalysisRun> = {
13270 let json_path = {
13271 let reg = state.registry.lock().await;
13272 reg.entries.first().and_then(|e| e.json_path.clone())
13273 };
13274 if let Some(p) = json_path {
13275 let json_str = tokio::fs::read_to_string(&p).await.ok();
13276 json_str
13277 .as_deref()
13278 .and_then(|s| serde_json::from_str(s).ok())
13279 } else {
13280 None
13281 }
13282 };
13283
13284 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13286
13287 let cov_json: String = latest_run
13289 .as_ref()
13290 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13291 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13292
13293 let _cov_tier_json: String = latest_run
13295 .as_ref()
13296 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13297 .map_or_else(
13298 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13299 compute_cov_tier_json_str,
13300 );
13301
13302 let total_tests: u64 = latest_run
13303 .as_ref()
13304 .map_or(0, |r| r.summary_totals.test_count);
13305 let total_assertions: u64 = latest_run
13306 .as_ref()
13307 .map_or(0, |r| r.summary_totals.test_assertion_count);
13308 let total_suites: u64 = latest_run
13309 .as_ref()
13310 .map_or(0, |r| r.summary_totals.test_suite_count);
13311 let total_code: u64 = latest_run
13312 .as_ref()
13313 .map_or(0, |r| r.summary_totals.code_lines);
13314 let workspace_density: f64 = if total_code > 0 {
13315 total_tests as f64 / total_code as f64 * 1000.0
13316 } else {
13317 0.0
13318 };
13319 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13320 r.totals_by_language
13321 .iter()
13322 .filter(|l| l.test_count > 0)
13323 .count()
13324 });
13325 let most_tested: String = latest_run
13326 .as_ref()
13327 .and_then(|r| {
13328 r.totals_by_language
13329 .iter()
13330 .filter(|l| l.test_count > 0)
13331 .max_by_key(|l| l.test_count)
13332 })
13333 .map_or_else(
13334 || "\u{2014}".to_string(),
13335 |l| l.language.display_name().to_string(),
13336 );
13337 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13338 r.per_file_records
13339 .iter()
13340 .filter(|f| f.raw_line_categories.test_count > 0)
13341 .count() as u64
13342 });
13343 let total_files_analyzed: u64 = latest_run
13344 .as_ref()
13345 .map_or(0, |r| r.summary_totals.files_analyzed);
13346 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13347
13348 let cov_line_pct_str: String = latest_run
13350 .as_ref()
13351 .filter(|r| r.summary_totals.coverage_lines_found > 0)
13352 .map_or_else(
13353 || "0".to_string(),
13354 |r| {
13355 format!(
13356 "{:.1}",
13357 r.summary_totals.coverage_lines_hit as f64
13358 / r.summary_totals.coverage_lines_found as f64
13359 * 100.0
13360 )
13361 },
13362 );
13363 let cov_fn_pct_str: String = latest_run
13364 .as_ref()
13365 .filter(|r| r.summary_totals.coverage_functions_found > 0)
13366 .map_or_else(
13367 || "0".to_string(),
13368 |r| {
13369 format!(
13370 "{:.1}",
13371 r.summary_totals.coverage_functions_hit as f64
13372 / r.summary_totals.coverage_functions_found as f64
13373 * 100.0
13374 )
13375 },
13376 );
13377 let cov_branch_pct_str: String = latest_run
13378 .as_ref()
13379 .filter(|r| r.summary_totals.coverage_branches_found > 0)
13380 .map_or_else(
13381 || "0".to_string(),
13382 |r| {
13383 format!(
13384 "{:.1}",
13385 r.summary_totals.coverage_branches_hit as f64
13386 / r.summary_totals.coverage_branches_found as f64
13387 * 100.0
13388 )
13389 },
13390 );
13391
13392 let cov_no_data_notice = if has_coverage {
13393 String::new()
13394 } else {
13395 String::from(
13396 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13397<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>
13398<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13399 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13400 <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>
13401 <span style="color:var(--muted);font-size:12px;">·</span>
13402 <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>
13403 <span style="color:var(--muted);font-size:12px;">·</span>
13404 <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>
13405 <span style="color:var(--muted);font-size:12px;">·</span>
13406 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
13407 <span style="color:var(--muted);font-size:12px;">·</span>
13408 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
13409</div>
13410<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13411</div>"#,
13412 )
13413 };
13414
13415 let workspace_density_str = format!("{workspace_density:.1}");
13416 let nonce = &csp_nonce;
13417 let toast_assets = sloc_toast_assets(nonce);
13418 let version = env!("CARGO_PKG_VERSION");
13419
13420 let watched_dirs_html: String = if state.server_mode {
13423 r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode \u2014 watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13424 } else {
13425 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13426 r#"<span class="watched-none">No folders watched \u2014 click Choose to add one</span>"#
13427 .to_string()
13428 } else {
13429 watched_dirs_list
13430 .iter()
13431 .fold(String::new(), |mut s, d| {
13432 use std::fmt::Write as _;
13433 let escaped =
13434 d.replace('&', "&").replace('"', """).replace('<', "<");
13435 write!(
13436 s,
13437 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>"#
13438 ).expect("write to String is infallible");
13439 s
13440 })
13441 };
13442 format!(
13443 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>"#
13444 )
13445 };
13446
13447 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13449
13450 let html = format!(
13451 r#"<!doctype html>
13452<html lang="en">
13453<head>
13454 <meta charset="utf-8" />
13455 <meta name="viewport" content="width=device-width, initial-scale=1" />
13456 <title>OxideSLOC | Test Metrics</title>
13457 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13458 <style nonce="{nonce}">
13459 :root {{
13460 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13461 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13462 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13463 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13464 --info-bg:#eef3ff; --info-text:#4467d8;
13465 }}
13466 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13467 *{{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;}}
13468 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13469 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13470 .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;}}
13471 @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));}}}}
13472 .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);}}
13473 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13474 .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));}}
13475 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13476 .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;}}
13477 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13478 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13479 @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; }} }}
13480 .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;}}
13481 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13482 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13483 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13484 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13485 .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;}}
13486 .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;}}
13487 .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;}}
13488 .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;}}
13489 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13490 .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);}}
13491 .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;}}
13492 .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;}}
13493 .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;}}
13494 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13495 .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;}}
13496 .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);}}
13497 .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;}}
13498 .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;}}
13499 .tz-select:focus{{border-color:var(--oxide);}}
13500 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13501 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13502 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13503 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13504 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13505 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13506 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13507 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
13508 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13509 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13510 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13511 .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;}}
13512 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}}
13513 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13514 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13515 .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);}}
13516 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13517 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13518 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13519 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13520 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13521 .chart-canvas-wrap{{position:relative;height:280px;}}
13522 .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;}}
13523 .chart-no-data svg{{opacity:0.35;}}
13524 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13525 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13526 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13527 .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;}}
13528 .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;}}
13529 .data-table tr:last-child td{{border-bottom:none;}}
13530 .data-table tbody tr:hover td{{background:var(--surface-2);}}
13531 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13532 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13533 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13534 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13535 .cov-gauge-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);min-width:0;}}
13536 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13537 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13538 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13539 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13540 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13541 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13542 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13543 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13544 .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;}}
13545 .chart-select:focus{{border-color:var(--accent);}}
13546 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13547 .trend-canvas-wrap{{position:relative;height:260px;}}
13548 .trend-controls-bar{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
13549 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13550 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13551 .site-footer a{{color:var(--muted);}}
13552 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13553 .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;}}
13554 .btn:hover{{background:var(--surface-2);}}
13555 .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;}}
13556 .export-btn:hover{{background:var(--line);}}
13557 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13558 .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;}}
13559 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13560 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13561 .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;}}
13562 .scope-sel:focus{{border-color:var(--accent);}}
13563 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13564 .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;}}
13565 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13566 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13567 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13568 .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;}}
13569 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13570 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13571 .watched-chip-rm:hover{{color:var(--oxide);}}
13572 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13573 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13574 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13575 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13576 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13577 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13578 .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;}}
13579 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13580 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13581 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13582 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13583 .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;}}
13584 .cov-file-search:focus{{border-color:var(--accent);}}
13585 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13586 .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;}}
13587 body.dark-theme .cov-file-search{{background:var(--surface);}}
13588 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13589 .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;}}
13590 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13591 .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;}}
13592 .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);}}
13593 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13594 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13595 .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;}}
13596 .chart-modal-close:hover{{opacity:.7;}}
13597 body.dark-theme .chart-modal{{background:var(--surface);}}
13598 </style>
13599</head>
13600<body>
13601 <div class="background-watermarks" aria-hidden="true">
13602 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13603 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13604 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13605 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13606 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13607 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13608 </div>
13609 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13610 <div class="top-nav">
13611 <div class="top-nav-inner">
13612 <a class="brand" href="/">
13613 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13614 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13615 </a>
13616 <div class="nav-right">
13617 <a class="nav-pill" href="/">Home</a>
13618 <div class="nav-dropdown">
13619 <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>
13620 <div class="nav-dropdown-menu">
13621 <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>
13622 </div>
13623 </div>
13624 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13625 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13626 <div class="nav-dropdown">
13627 <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>
13628 <div class="nav-dropdown-menu">
13629 <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>
13630 </div>
13631 </div>
13632 <div class="server-status-wrap" id="server-status-wrap">
13633 <div class="nav-pill server-online-pill" id="server-status-pill">
13634 <span class="status-dot" id="status-dot"></span>
13635 <span id="server-status-label">Server</span>
13636 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13637 </div>
13638 <div class="server-status-tip">
13639 OxideSLOC is running — accessible on your network.
13640 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13641 </div>
13642 </div>
13643 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13644 <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>
13645 </button>
13646 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13647 <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>
13648 <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>
13649 </button>
13650 </div>
13651 </div>
13652 </div>
13653
13654 <div class="page">
13655 {watched_dirs_html}
13656 <div class="scope-bar">
13657 <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>
13658 <span class="scope-label">Scope</span>
13659 <div class="scope-sel-wrap">
13660 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13661 <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);">
13662 <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>
13663 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13664 </div>
13665 </div>
13666 </div>
13667 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13668 <div class="stat-chip"><div class="stat-chip-val" id="chip-total">{total_tests}</div><div class="stat-chip-label">Test Functions</div><div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div><div class="stat-chip-exact" id="chip-total-exact"></div></div>
13669 <div class="stat-chip"><div class="stat-chip-val" id="chip-assertions">{total_assertions}</div><div class="stat-chip-label">Assertions</div><div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div><div class="stat-chip-exact" id="chip-assertions-exact"></div></div>
13670 <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>
13671 <div class="stat-chip"><div class="stat-chip-val" id="chip-test-files">{test_files_count} / {total_files_analyzed}</div><div class="stat-chip-label">Test Files</div><div class="stat-chip-tip">Files containing at least one test definition out of total analyzed files</div><div class="stat-chip-exact" id="chip-test-files-exact"></div></div>
13672 </div>
13673 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13674 <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>
13675 <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>
13676 <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>
13677 <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>
13678 </div>
13679
13680 <div class="panel" id="viz-panel">
13681 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13682 <span>Visualizations</span>
13683 <div style="display:flex;gap:8px;flex-wrap:wrap;">
13684 <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download test metrics as Excel workbook (.xlsx)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export Excel</button>
13685 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save charts as PNG image"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Export PNG</button>
13686 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export printable PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> Export PDF</button>
13687 </div>
13688 </div>
13689
13690 <div class="chart-box" style="margin-bottom:18px;">
13691 <div class="chart-box-header">
13692 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13693 <div style="display:flex;gap:8px;align-items:center;">
13694 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
13695 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13696 </div>
13697 </div>
13698 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
13699 <div class="trend-controls-bar">
13700 <label>Y Metric:
13701 <select class="chart-select" id="tm-trend-y">
13702 <option value="test_count" selected>Test Definitions</option>
13703 <option value="code_lines">Code Lines</option>
13704 </select>
13705 </label>
13706 <label>X Axis:
13707 <select class="chart-select" id="tm-trend-x">
13708 <option value="commit" selected>By Commit</option>
13709 <option value="time">By Time</option>
13710 </select>
13711 </label>
13712 <label id="tm-sub-label" style="display:none;">Submodule:
13713 <select class="chart-select" id="tm-trend-sub">
13714 <option value="">All (project total)</option>
13715 </select>
13716 </label>
13717 <label>Chart Size:
13718 <select class="chart-select" id="tm-trend-size">
13719 <option value="200">Compact</option>
13720 <option value="260" selected>Normal</option>
13721 <option value="360">Large</option>
13722 </select>
13723 </label>
13724 </div>
13725 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13726 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13727 </div>
13728
13729 <div class="chart-row">
13730 <div class="chart-box">
13731 <div class="chart-box-header">
13732 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13733 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13734 </div>
13735 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13736 <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>
13737 </div>
13738 <div class="chart-box">
13739 <div class="chart-box-header">
13740 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13741 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13742 </div>
13743 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13744 <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>
13745 </div>
13746 </div>
13747
13748 <div class="chart-row">
13749 <div class="chart-box">
13750 <div class="chart-box-header">
13751 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13752 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13753 </div>
13754 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13755 <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>
13756 </div>
13757 <div class="chart-box" id="suites-chart-box">
13758 <div class="chart-box-header">
13759 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13760 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13761 </div>
13762 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13763 <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>
13764 </div>
13765 </div>
13766
13767 <div class="chart-row">
13768 <div class="chart-box">
13769 <div class="chart-box-title">Test Files Breakdown</div>
13770 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13771 <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>
13772 </div>
13773 <div class="chart-box">
13774 <div class="chart-box-title">Test Composition</div>
13775 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13776 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13777 <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>
13778 </div>
13779 </div>
13780 </div>
13781
13782 <div class="panel">
13783 <h1>Test Metrics</h1>
13784 <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>
13785
13786 <div class="section-header">Language Breakdown</div>
13787 {cov_no_data_notice}
13788 <div style="overflow-x:auto;">
13789 <table class="data-table" id="lang-table">
13790 <thead><tr>
13791 <th>Language</th>
13792 <th class="num">Test Fns</th>
13793 <th class="num">Assertions</th>
13794 <th class="num">Suites</th>
13795 <th class="num">Code Lines</th>
13796 <th class="num">Files</th>
13797 <th class="num">Density / 1K</th>
13798 <th>Relative Density</th>
13799 </tr></thead>
13800 <tbody id="lang-tbody"></tbody>
13801 </table>
13802 </div>
13803 </div>
13804
13805 <div class="panel" id="cov-panel" style="display:none;">
13806 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
13807 <div class="cov-gauge-row" id="cov-gauges">
13808 <div class="cov-gauge-card">
13809 <div class="cov-gauge-label">Line Coverage</div>
13810 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
13811 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
13812 <div class="cov-gauge-sub">Lines hit / instrumented</div>
13813 </div>
13814 <div class="cov-gauge-card">
13815 <div class="cov-gauge-label">Function Coverage</div>
13816 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
13817 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
13818 <div class="cov-gauge-sub">Functions hit / found</div>
13819 </div>
13820 <div class="cov-gauge-card">
13821 <div class="cov-gauge-label">Branch Coverage</div>
13822 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
13823 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
13824 <div class="cov-gauge-sub">Branches hit / found</div>
13825 </div>
13826 </div>
13827 <div class="chart-row">
13828 <div class="chart-box">
13829 <div class="chart-box-title">Line Coverage % by Language</div>
13830 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
13831 </div>
13832 <div class="chart-box">
13833 <div class="chart-box-title">Coverage Tier Distribution</div>
13834 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
13835 </div>
13836 </div>
13837
13838 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
13839 <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>
13840 <div class="cov-file-toolbar">
13841 <div class="cov-filter-tabs" id="cov-filter-tabs">
13842 <button class="cov-tab active" data-tier="all">All</button>
13843 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
13844 <button class="cov-tab" data-tier="low">Low (<50%)</button>
13845 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
13846 <button class="cov-tab" data-tier="high">High (≥80%)</button>
13847 </div>
13848 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename\u2026">
13849 </div>
13850 <div style="overflow-x:auto;">
13851 <table class="data-table" id="cov-file-table">
13852 <thead><tr>
13853 <th>File</th>
13854 <th>Lang</th>
13855 <th class="num">Line %</th>
13856 <th class="num">Lines Hit / Found</th>
13857 <th class="num">Fn %</th>
13858 <th class="num">Fns Hit / Found</th>
13859 </tr></thead>
13860 <tbody id="cov-file-tbody"></tbody>
13861 </table>
13862 </div>
13863 <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>
13864 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
13865 </div>
13866
13867 </div>
13868
13869 <footer class="site-footer">
13870 local code analysis - metrics, history and reports
13871 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
13872 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13873 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13874 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13875 · <a href="/api-docs" rel="noopener">REST API</a>
13876 </footer>
13877
13878 <script nonce="{nonce}">
13879 (function() {{
13880 // Theme
13881 var b = document.body;
13882 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
13883 var tgl = document.getElementById('theme-toggle');
13884 if (tgl) tgl.addEventListener('click', function() {{
13885 var d = b.classList.toggle('dark-theme');
13886 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
13887 }});
13888
13889 // Watermarks
13890 (function() {{
13891 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13892 if (!wms.length) return;
13893 var placed = [];
13894 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;}}
13895 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];}}
13896 var half=Math.floor(wms.length/2);
13897 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;}});
13898 }})();
13899
13900 // Code particles
13901 (function() {{
13902 var container = document.getElementById('code-particles');
13903 if (!container) return;
13904 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
13905 for (var i = 0; i < 36; i++) {{
13906 (function(idx) {{
13907 var el = document.createElement('span');
13908 el.className = 'code-particle';
13909 el.textContent = snippets[idx % snippets.length];
13910 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
13911 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
13912 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
13913 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';
13914 container.appendChild(el);
13915 }})(i);
13916 }}
13917 }})();
13918
13919 // Settings modal
13920 (function() {{
13921 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'}}];
13922 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);}});}}
13923 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
13924 var btn=document.getElementById('settings-btn');if(!btn)return;
13925 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13926 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>';
13927 document.body.appendChild(m);
13928 var g=document.getElementById('scheme-grid');
13929 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);}});
13930 var cl=document.getElementById('settings-close');
13931 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');}});
13932 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13933 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13934 }})();
13935
13936 // Watched folder picker
13937 (function() {{
13938 var btn = document.getElementById('add-watched-btn');
13939 if (!btn) return;
13940 btn.addEventListener('click', function() {{
13941 fetch('/pick-directory?kind=reports')
13942 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
13943 .then(function(data) {{
13944 if (!data.cancelled && data.selected_path) {{
13945 var form = document.createElement('form');
13946 form.method = 'POST';
13947 form.action = '/watched-dirs/add';
13948 var ri = document.createElement('input');
13949 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
13950 var fi = document.createElement('input');
13951 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
13952 form.appendChild(ri); form.appendChild(fi);
13953 document.body.appendChild(form);
13954 form.submit();
13955 }}
13956 }})
13957 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
13958 }});
13959 }})();
13960 }})();
13961 </script>
13962
13963 <script src="/static/chart.js" nonce="{nonce}"></script>
13964 <script nonce="{nonce}">
13965 (function() {{
13966 var SCOPE_DATA = {scope_data_json};
13967 var currentRoot = '__all__';
13968 var currentSub = '';
13969 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
13970 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
13971 var ALL_CHARTS = [];
13972 var currentLangTests = [];
13973 var currentTrendPts = [];
13974
13975 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();}}
13976 function fmtFull(n){{return Number(n).toLocaleString();}}
13977 function isDark(){{return document.body.classList.contains('dark-theme');}}
13978 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
13979 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
13980 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
13981
13982 function makeDlPlugin(fmtFn, anchor) {{
13983 return {{
13984 afterDatasetsDraw: function(chart) {{
13985 var ctx = chart.ctx;
13986 var tc = txtClr();
13987 chart.data.datasets.forEach(function(ds, di) {{
13988 var meta = chart.getDatasetMeta(di);
13989 meta.data.forEach(function(el, idx) {{
13990 var label = fmtFn(ds.data[idx], di, idx);
13991 if (label == null || label === '') return;
13992 ctx.save();
13993 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
13994 ctx.fillStyle = tc;
13995 if (anchor === 'top') {{
13996 ctx.textAlign = 'center';
13997 ctx.textBaseline = 'bottom';
13998 ctx.fillText(String(label), el.x, el.y - 5);
13999 }} else {{
14000 ctx.textAlign = 'left';
14001 ctx.textBaseline = 'middle';
14002 ctx.fillText(String(label), el.x + 5, el.y);
14003 }}
14004 ctx.restore();
14005 }});
14006 }});
14007 }}
14008 }};
14009 }}
14010
14011 // Cursor: pointer over chart data, default over empty chart area.
14012 function chartCursor(e, els) {{
14013 var t = e.native && e.native.target;
14014 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14015 }}
14016 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14017
14018 // Plugin: draws % labels inside each doughnut slice.
14019 var donutPctPlugin = {{
14020 afterDatasetsDraw: function(chart) {{
14021 var ctx = chart.ctx;
14022 chart.data.datasets.forEach(function(ds, di) {{
14023 var meta = chart.getDatasetMeta(di);
14024 if (meta.hidden) return;
14025 var total = 0;
14026 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14027 if (!total) return;
14028 meta.data.forEach(function(arc, i) {{
14029 if (arc.hidden) return;
14030 var val = ds.data[i] || 0;
14031 var pct = val / total * 100;
14032 if (pct < 3) return;
14033 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14034 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14035 var tx = arc.x + midR * Math.cos(midAngle);
14036 var ty = arc.y + midR * Math.sin(midAngle);
14037 ctx.save();
14038 ctx.textAlign = 'center';
14039 ctx.textBaseline = 'middle';
14040 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14041 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14042 ctx.shadowBlur = 3;
14043 ctx.fillStyle = '#fff';
14044 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14045 ctx.restore();
14046 }});
14047 }});
14048 }}
14049 }};
14050
14051 function makeTmOverlay(title, subtitle, h) {{
14052 var overlay = document.createElement('div');
14053 overlay.className = 'chart-modal-overlay';
14054 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14055 var ch = Math.min(h || 560, maxH);
14056 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14057 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>';
14058 document.body.appendChild(overlay);
14059 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14060 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14061 return document.getElementById('tm-modal-canvas');
14062 }}
14063
14064 function getDataset() {{
14065 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14066 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14067 return r;
14068 }}
14069 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14070
14071 function showNoData(id, show) {{
14072 var el = document.getElementById(id);
14073 if (!el) return;
14074 var wrap = el.previousElementSibling;
14075 el.style.display = show ? '' : 'none';
14076 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14077 }}
14078
14079 function renderTestCharts(D) {{
14080 currentLangTests = D || [];
14081 testsChart = destroyChart(testsChart);
14082 densityChart = destroyChart(densityChart);
14083 if (!D || !D.length) {{
14084 showNoData('no-data-tests', true);
14085 showNoData('no-data-density', true);
14086 return;
14087 }}
14088 showNoData('no-data-tests', false);
14089 showNoData('no-data-density', false);
14090 var top15 = D.slice(0, 15);
14091 var canvas1 = document.getElementById('canvas-tests');
14092 if (canvas1) {{
14093 testsChart = new Chart(canvas1, {{
14094 type: 'bar',
14095 data: {{
14096 labels: top15.map(function(d){{ return d.lang; }}),
14097 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14098 }},
14099 options: {{
14100 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14101 layout: {{ padding: {{ right: 64 }} }},
14102 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14103 scales: {{
14104 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14105 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14106 }}
14107 }},
14108 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14109 }});
14110 ALL_CHARTS.push(testsChart);
14111 }}
14112 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14113 var canvas2 = document.getElementById('canvas-density');
14114 if (canvas2) {{
14115 densityChart = new Chart(canvas2, {{
14116 type: 'bar',
14117 data: {{
14118 labels: topD.map(function(d){{ return d.lang; }}),
14119 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 }}]
14120 }},
14121 options: {{
14122 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14123 layout: {{ padding: {{ right: 64 }} }},
14124 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14125 scales: {{
14126 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14127 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14128 }}
14129 }},
14130 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14131 }});
14132 ALL_CHARTS.push(densityChart);
14133 }}
14134 }}
14135
14136 function renderAssertionsChart(D) {{
14137 assertionsChart = destroyChart(assertionsChart);
14138 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14139 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14140 var canvas = document.getElementById('canvas-assertions');
14141 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14142 showNoData('no-data-assertions', false);
14143 assertionsChart = new Chart(canvas, {{
14144 type: 'bar',
14145 data: {{
14146 labels: top15.map(function(d){{ return d.lang; }}),
14147 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14148 }},
14149 options: {{
14150 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14151 layout: {{ padding: {{ right: 64 }} }},
14152 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14153 scales: {{
14154 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14155 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14156 }}
14157 }},
14158 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14159 }});
14160 ALL_CHARTS.push(assertionsChart);
14161 }}
14162
14163 function renderSuitesChart(D) {{
14164 suitesChart = destroyChart(suitesChart);
14165 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14166 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14167 var canvas = document.getElementById('canvas-suites');
14168 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14169 showNoData('no-data-suites', false);
14170 suitesChart = new Chart(canvas, {{
14171 type: 'bar',
14172 data: {{
14173 labels: top15.map(function(d){{ return d.lang; }}),
14174 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 }}]
14175 }},
14176 options: {{
14177 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14178 layout: {{ padding: {{ right: 64 }} }},
14179 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14180 scales: {{
14181 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14182 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14183 }}
14184 }},
14185 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14186 }});
14187 ALL_CHARTS.push(suitesChart);
14188 }}
14189
14190 function renderFilesChart(totals) {{
14191 filesChart = destroyChart(filesChart);
14192 var canvas = document.getElementById('canvas-files');
14193 if (!canvas) return;
14194 var testF = totals.test_files || 0;
14195 var totalF = totals.total_files || 0;
14196 var nonTest = Math.max(0, totalF - testF);
14197 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14198 showNoData('no-data-files', false);
14199 var dark = isDark();
14200 filesChart = new Chart(canvas, {{
14201 type: 'doughnut',
14202 data: {{
14203 labels: ['Test Files', 'Non-Test Files'],
14204 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14205 }},
14206 options: {{
14207 responsive: true, maintainAspectRatio: false, cutout: '62%',
14208 onHover: chartCursor,
14209 plugins: {{
14210 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14211 generateLabels: function(chart) {{
14212 var ds = chart.data.datasets[0];
14213 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14214 return chart.data.labels.map(function(lbl, i) {{
14215 var val = ds.data[i] || 0;
14216 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14217 return {{
14218 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14219 fillStyle: ds.backgroundColor[i],
14220 strokeStyle: ds.borderColor,
14221 lineWidth: ds.borderWidth,
14222 hidden: false,
14223 index: i,
14224 datasetIndex: 0
14225 }};
14226 }});
14227 }}
14228 }},
14229 onHover: function(e, item, leg) {{
14230 var ch = leg.chart;
14231 var t = e.native && e.native.target;
14232 if (t) t.style.cursor = 'pointer';
14233 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14234 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14235 ch.update();
14236 }},
14237 onLeave: function(e, item, leg) {{
14238 var ch = leg.chart;
14239 var t = e.native && e.native.target;
14240 if (t) t.style.cursor = 'default';
14241 ch.setActiveElements([]);
14242 ch.tooltip.setActiveElements([], {{}});
14243 ch.update('none');
14244 }}
14245 }},
14246 tooltip: {{ callbacks: {{ label: function(ctx) {{
14247 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14248 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14249 }} }} }}
14250 }}
14251 }},
14252 plugins: [donutPctPlugin]
14253 }});
14254 ALL_CHARTS.push(filesChart);
14255 }}
14256
14257 function renderCompositionChart(totals) {{
14258 compositionChart = destroyChart(compositionChart);
14259 var canvas = document.getElementById('canvas-composition');
14260 if (!canvas) return;
14261 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14262 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14263 showNoData('no-data-composition', false);
14264 compositionChart = new Chart(canvas, {{
14265 type: 'bar',
14266 data: {{
14267 labels: ['Test Functions', 'Assertions', 'Test Suites'],
14268 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14269 }},
14270 options: {{
14271 responsive: true, maintainAspectRatio: false,
14272 layout: {{ padding: {{ top: 22 }} }},
14273 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14274 scales: {{
14275 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14276 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14277 }}
14278 }},
14279 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14280 }});
14281 ALL_CHARTS.push(compositionChart);
14282 }}
14283
14284 function renderCovCharts(covD, tiers) {{
14285 covChart = destroyChart(covChart);
14286 tierChart = destroyChart(tierChart);
14287 var covCanvas = document.getElementById('canvas-cov');
14288 if (covCanvas && covD && covD.length) {{
14289 covChart = new Chart(covCanvas, {{
14290 type: 'bar',
14291 data: {{
14292 labels: covD.map(function(d){{ return d.lang; }}),
14293 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 }}]
14294 }},
14295 options: {{
14296 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14297 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14298 scales: {{
14299 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14300 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14301 }}
14302 }}
14303 }});
14304 ALL_CHARTS.push(covChart);
14305 }}
14306 var tierCanvas = document.getElementById('canvas-cov-tiers');
14307 if (tierCanvas && tiers) {{
14308 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14309 tierChart = new Chart(tierCanvas, {{
14310 type: 'doughnut',
14311 data: {{
14312 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14313 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14314 }},
14315 options: {{
14316 responsive: true, maintainAspectRatio: false, cutout: '62%',
14317 onHover: chartCursor,
14318 plugins: {{
14319 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14320 onHover: function(e, item, leg) {{
14321 var ch = leg.chart;
14322 var t = e.native && e.native.target;
14323 if (t) t.style.cursor = 'pointer';
14324 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14325 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14326 ch.update();
14327 }},
14328 onLeave: function(e, item, leg) {{
14329 var ch = leg.chart;
14330 var t = e.native && e.native.target;
14331 if (t) t.style.cursor = 'default';
14332 ch.setActiveElements([]);
14333 ch.tooltip.setActiveElements([], {{}});
14334 ch.update('none');
14335 }}
14336 }},
14337 tooltip: {{ callbacks: {{ label: function(ctx) {{
14338 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14339 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14340 }} }} }}
14341 }}
14342 }},
14343 plugins: [donutPctPlugin]
14344 }});
14345 ALL_CHARTS.push(tierChart);
14346 }}
14347 }}
14348
14349 function buildLangTable(D) {{
14350 var tbody = document.getElementById('lang-tbody');
14351 if (!tbody) return;
14352 if (!D || !D.length) {{
14353 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>';
14354 return;
14355 }}
14356 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14357 tbody.innerHTML = D.map(function(d) {{
14358 var barW = Math.round(d.density / maxDensity * 120);
14359 return '<tr>' +
14360 '<td><strong>' + d.lang + '</strong></td>' +
14361 '<td class="num">' + fmtFull(d.tests) + '</td>' +
14362 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14363 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14364 '<td class="num">' + fmtFull(d.code) + '</td>' +
14365 '<td class="num">' + fmtFull(d.files) + '</td>' +
14366 '<td class="num">' + d.density.toFixed(2) + '</td>' +
14367 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14368 '</tr>';
14369 }}).join('');
14370 }}
14371
14372 var covFileData = [];
14373 var covFileTier = 'all';
14374 var covFileSearch = '';
14375
14376 function pctBadge(pct) {{
14377 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14378 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14379 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14380 }}
14381
14382 function buildCovFileTable() {{
14383 var tbody = document.getElementById('cov-file-tbody');
14384 var empty = document.getElementById('cov-file-empty');
14385 var count = document.getElementById('cov-file-count');
14386 if (!tbody) return;
14387 var srch = covFileSearch.toLowerCase();
14388 var filtered = covFileData.filter(function(f) {{
14389 if (covFileTier === 'zero' && f.line_pct > 0) return false;
14390 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14391 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14392 if (covFileTier === 'high' && f.line_pct < 80) return false;
14393 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14394 return true;
14395 }});
14396 if (!filtered.length) {{
14397 tbody.innerHTML = '';
14398 if (empty) empty.style.display = '';
14399 if (count) count.textContent = '';
14400 return;
14401 }}
14402 if (empty) empty.style.display = 'none';
14403 var shown = Math.min(filtered.length, 500);
14404 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14405 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14406 var fnCol = f.fn_pct < 0
14407 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14408 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14409 return '<tr>' +
14410 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
14411 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14412 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14413 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14414 fnCol +
14415 '</tr>';
14416 }}).join('');
14417 }}
14418
14419 (function() {{
14420 var tabs = document.getElementById('cov-filter-tabs');
14421 if (tabs) {{
14422 tabs.addEventListener('click', function(e) {{
14423 var btn = e.target.closest('.cov-tab');
14424 if (!btn) return;
14425 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14426 btn.classList.add('active');
14427 covFileTier = btn.getAttribute('data-tier');
14428 buildCovFileTable();
14429 }});
14430 }}
14431 var srch = document.getElementById('cov-file-search');
14432 if (srch) {{
14433 srch.addEventListener('input', function() {{
14434 covFileSearch = this.value;
14435 buildCovFileTable();
14436 }});
14437 }}
14438 }})();
14439
14440 function updateCovGauges(t) {{
14441 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14442 var el;
14443 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14444 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14445 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14446 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14447 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14448 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14449 }}
14450
14451 function applyScope() {{
14452 var d = getDataset();
14453 var t = d.totals;
14454 var el;
14455 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14456 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14457 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14458 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14459 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14460 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14461 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14462 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14463 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14464 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14465 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14466 renderTestCharts(d.lang_tests);
14467 renderAssertionsChart(d.lang_tests);
14468 renderSuitesChart(d.lang_tests);
14469 renderFilesChart(t);
14470 renderCompositionChart(t);
14471 buildLangTable(d.lang_tests);
14472 var covPanel = document.getElementById('cov-panel');
14473 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14474 if (d.has_coverage) {{
14475 renderCovCharts(d.cov, d.cov_tiers);
14476 updateCovGauges(t);
14477 covFileData = d.file_cov || [];
14478 covFileTier = 'all';
14479 covFileSearch = '';
14480 var tabs = document.getElementById('cov-filter-tabs');
14481 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14482 var srch = document.getElementById('cov-file-search');
14483 if (srch) srch.value = '';
14484 buildCovFileTable();
14485 }}
14486 loadTrend();
14487 }}
14488
14489 // Populate scope-root-sel from SCOPE_DATA keys
14490 (function() {{
14491 var sel = document.getElementById('scope-root-sel');
14492 if (!sel) return;
14493 Object.keys(SCOPE_DATA).forEach(function(k) {{
14494 if (k === '__all__') return;
14495 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14496 }});
14497 }})();
14498
14499 document.getElementById('scope-root-sel').addEventListener('change', function() {{
14500 currentRoot = this.value;
14501 currentSub = '';
14502 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14503 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14504 var subWrap = document.getElementById('scope-sub-wrap');
14505 var subSel = document.getElementById('scope-sub-sel');
14506 subSel.innerHTML = '<option value="">Entire project</option>';
14507 if (subNames.length) {{
14508 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14509 subWrap.style.display = 'flex';
14510 }} else {{
14511 subWrap.style.display = 'none';
14512 }}
14513 applyScope();
14514 }});
14515
14516 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14517 currentSub = this.value;
14518 applyScope();
14519 }});
14520
14521 var allTrendData = [];
14522
14523 var TM_Y_META = {{
14524 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14525 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
14526 }};
14527
14528 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14529 function hexRgb(hex) {{
14530 var h = String(hex).replace('#', '');
14531 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14532 var n = parseInt(h, 16);
14533 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14534 }}
14535 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14536 // tint at the top to transparent at the bottom (no flat solid block).
14537 function tmTrendGradient(ctx2, chartArea, color) {{
14538 var rgb = hexRgb(color);
14539 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14540 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
14541 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14542 g.addColorStop(1, 'rgba(' + rgb + ',0)');
14543 return g;
14544 }}
14545
14546 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14547 // so linear interpolation between adjacent points matches the drawn line).
14548 function tmLineYAt(chart, px) {{
14549 var meta = chart.getDatasetMeta(0);
14550 if (!meta || !meta.data || !meta.data.length) return null;
14551 var d = meta.data;
14552 if (px <= d[0].x) return d[0].y;
14553 for (var i = 1; i < d.length; i++) {{
14554 if (px <= d[i].x) {{
14555 var span = d[i].x - d[i - 1].x;
14556 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14557 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14558 }}
14559 }}
14560 return d[d.length - 1].y;
14561 }}
14562
14563 // Plugin: only show the tooltip / finger cursor when the pointer is over the
14564 // gradient fill (inside the plot and at/below the line) — never in the empty
14565 // space above the line. Outside the fill we retype the event as 'mouseout' so
14566 // the core interaction dismisses any active tooltip on its own.
14567 var tmFillGuard = {{
14568 id: 'tmFillGuard',
14569 beforeEvent: function(chart, args) {{
14570 var e = args.event;
14571 if (!e || e.type !== 'mousemove') return;
14572 var ca = chart.chartArea;
14573 if (!ca) return;
14574 var inFill = false;
14575 if (e.x >= ca.left && e.x <= ca.right) {{
14576 var ly = tmLineYAt(chart, e.x);
14577 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14578 }}
14579 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14580 if (!inFill) {{ e.type = 'mouseout'; }}
14581 }}
14582 }};
14583
14584 // Single source of truth for the test-metrics trend chart config so the inline
14585 // chart and the Full View modal render identically (straight segments, gradient
14586 // fill, white-ringed points, gradient-only interactivity).
14587 function buildTmTrendConfig(pts, ctrl, meta) {{
14588 return {{
14589 type: 'line',
14590 data: {{
14591 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14592 datasets: [{{
14593 label: meta.label,
14594 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14595 borderColor: meta.color,
14596 borderWidth: 2.5,
14597 backgroundColor: function(context) {{
14598 var ca = context.chart.chartArea;
14599 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14600 return tmTrendGradient(context.chart.ctx, ca, meta.color);
14601 }},
14602 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14603 pointBorderColor: '#fff',
14604 pointBorderWidth: 2,
14605 pointRadius: 6,
14606 pointHoverRadius: 9,
14607 pointHoverBorderWidth: 2.5,
14608 fill: true, tension: 0
14609 }}]
14610 }},
14611 options: {{
14612 responsive: true, maintainAspectRatio: false,
14613 layout: {{ padding: {{ top: 22 }} }},
14614 interaction: {{ mode: 'index', intersect: false }},
14615 plugins: {{
14616 legend: {{ display: false }},
14617 tooltip: {{
14618 mode: 'index', intersect: false,
14619 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14620 }}
14621 }},
14622 scales: {{
14623 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14624 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14625 }}
14626 }},
14627 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14628 }};
14629 }}
14630
14631 function getTrendControls() {{
14632 var ySel = document.getElementById('tm-trend-y');
14633 var xSel = document.getElementById('tm-trend-x');
14634 var sizeSel = document.getElementById('tm-trend-size');
14635 var subSel = document.getElementById('tm-trend-sub');
14636 return {{
14637 yKey: ySel ? ySel.value : 'test_count',
14638 xMode: xSel ? xSel.value : 'commit',
14639 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
14640 submod: subSel ? subSel.value : ''
14641 }};
14642 }}
14643
14644 function makeTrendLabel(d, xMode) {{
14645 if (xMode === 'commit') {{
14646 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14647 }}
14648 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14649 }}
14650
14651 function buildTrend(data) {{
14652 allTrendData = data || [];
14653 renderTrend();
14654 }}
14655
14656 function renderTrend() {{
14657 var data = allTrendData;
14658 var ctrl = getTrendControls();
14659 var trendCanvas = document.getElementById('canvas-trend');
14660 var trendWrap = document.getElementById('trend-canvas-wrap');
14661 var trendEmpty = document.getElementById('trend-empty');
14662
14663 // Apply chart size
14664 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14665
14666 // Filter by submodule if selected (entries from project_label match)
14667 var pts = data.slice().reverse();
14668 if (ctrl.submod) {{
14669 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14670 }}
14671
14672 currentTrendPts = pts;
14673
14674 if (!pts.length) {{
14675 if (trendCanvas) trendCanvas.style.display = 'none';
14676 if (trendEmpty) trendEmpty.style.display = '';
14677 return;
14678 }}
14679 if (trendCanvas) trendCanvas.style.display = '';
14680 if (trendEmpty) trendEmpty.style.display = 'none';
14681
14682 trendChart = destroyChart(trendChart);
14683 if (!trendCanvas) return;
14684
14685 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14686
14687 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14688 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14689 ALL_CHARTS.push(trendChart);
14690
14691 // Populate submodule selector from unique project_labels
14692 var subSel = document.getElementById('tm-trend-sub');
14693 var subLabel = document.getElementById('tm-sub-label');
14694 if (subSel && data.length) {{
14695 var projects = [];
14696 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14697 if (projects.length > 1) {{
14698 var curVal = subSel.value;
14699 subSel.innerHTML = '<option value="">All (project total)</option>';
14700 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14701 if (subLabel) subLabel.style.display = '';
14702 }} else {{
14703 if (subLabel) subLabel.style.display = 'none';
14704 }}
14705 }}
14706 }}
14707
14708 // ── Full View expand buttons ──────────────────────────────────────────────
14709 (function() {{
14710 var btn = document.getElementById('tests-expand-btn');
14711 if (!btn) return;
14712 btn.addEventListener('click', function() {{
14713 var D = currentLangTests;
14714 if (!D || !D.length) return;
14715 var top15 = D.slice(0, 15);
14716 var h = Math.max(320, top15.length * 36 + 80);
14717 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14718 if (!canvas) return;
14719 new Chart(canvas, {{
14720 type: 'bar',
14721 data: {{
14722 labels: top15.map(function(d){{ return d.lang; }}),
14723 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14724 }},
14725 options: {{
14726 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14727 layout: {{ padding: {{ right: 72 }} }},
14728 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14729 scales: {{
14730 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14731 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14732 }}
14733 }},
14734 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14735 }});
14736 }});
14737 }})();
14738
14739 (function() {{
14740 var btn = document.getElementById('density-expand-btn');
14741 if (!btn) return;
14742 btn.addEventListener('click', function() {{
14743 var D = currentLangTests;
14744 if (!D || !D.length) return;
14745 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14746 var h = Math.max(320, topD.length * 36 + 80);
14747 var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14748 if (!canvas) return;
14749 new Chart(canvas, {{
14750 type: 'bar',
14751 data: {{
14752 labels: topD.map(function(d){{ return d.lang; }}),
14753 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 }}]
14754 }},
14755 options: {{
14756 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14757 layout: {{ padding: {{ right: 72 }} }},
14758 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14759 scales: {{
14760 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14761 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14762 }}
14763 }},
14764 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14765 }});
14766 }});
14767 }})();
14768
14769 (function() {{
14770 var btn = document.getElementById('trend-expand-btn');
14771 if (!btn) return;
14772 btn.addEventListener('click', function() {{
14773 var pts = currentTrendPts;
14774 if (!pts || !pts.length) return;
14775 var ctrl = getTrendControls();
14776 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14777 var title = meta.label + ' Trend \u2014 Full View';
14778 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14779 if (!canvas) return;
14780 // Reuse the exact inline-chart config so Full View matches the default view
14781 // (straight segments + gradient-only interactivity), just larger.
14782 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14783 }});
14784 }})();
14785
14786 (function() {{
14787 var btn = document.getElementById('assertions-expand-btn');
14788 if (!btn) return;
14789 btn.addEventListener('click', function() {{
14790 var D = currentLangTests;
14791 if (!D || !D.length) return;
14792 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14793 if (!top15.length) return;
14794 var h = Math.max(320, top15.length * 36 + 80);
14795 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14796 if (!canvas) return;
14797 new Chart(canvas, {{
14798 type: 'bar',
14799 data: {{
14800 labels: top15.map(function(d){{ return d.lang; }}),
14801 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14802 }},
14803 options: {{
14804 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14805 layout: {{ padding: {{ right: 72 }} }},
14806 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14807 scales: {{
14808 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14809 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14810 }}
14811 }},
14812 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14813 }});
14814 }});
14815 }})();
14816
14817 (function() {{
14818 var btn = document.getElementById('suites-expand-btn');
14819 if (!btn) return;
14820 btn.addEventListener('click', function() {{
14821 var D = currentLangTests;
14822 if (!D || !D.length) return;
14823 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14824 if (!top15.length) return;
14825 var h = Math.max(320, top15.length * 36 + 80);
14826 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
14827 if (!canvas) return;
14828 new Chart(canvas, {{
14829 type: 'bar',
14830 data: {{
14831 labels: top15.map(function(d){{ return d.lang; }}),
14832 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 }}]
14833 }},
14834 options: {{
14835 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14836 layout: {{ padding: {{ right: 72 }} }},
14837 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14838 scales: {{
14839 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14840 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14841 }}
14842 }},
14843 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14844 }});
14845 }});
14846 }})();
14847
14848 // Wire trend control selectors — re-render without re-fetching
14849 (function() {{
14850 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
14851 var el = document.getElementById(id);
14852 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
14853 }});
14854 }})();
14855
14856 function loadTrend() {{
14857 var url = '/api/metrics/history?limit=100';
14858 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
14859 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
14860 buildTrend(data);
14861 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
14862 var btn = document.getElementById('multi-compare-trend-btn');
14863 if (btn) {{
14864 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
14865 if (ids.length >= 2) {{
14866 btn.style.display = '';
14867 btn.onclick = function() {{
14868 // Reverse so oldest first (API returns newest first).
14869 var sorted = ids.slice().reverse();
14870 if (sorted.length === 2) {{
14871 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
14872 }} else {{
14873 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
14874 }}
14875 }};
14876 }} else {{
14877 btn.style.display = 'none';
14878 }}
14879 }}
14880 }}).catch(function(){{
14881 var trendEmpty = document.getElementById('trend-empty');
14882 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
14883 }});
14884 }}
14885
14886 // Re-render charts on theme toggle
14887 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
14888 setTimeout(function() {{
14889 ALL_CHARTS.forEach(function(c) {{
14890 if (c && c.options && c.options.scales) {{
14891 Object.values(c.options.scales).forEach(function(ax) {{
14892 if (ax.grid) ax.grid.color = clr();
14893 if (ax.ticks) ax.ticks.color = txtClr();
14894 }});
14895 c.update();
14896 }}
14897 }});
14898 }}, 80);
14899 }});
14900
14901 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
14902 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14903 function tmExportMeta() {{
14904 var sel = document.getElementById('scope-sel');
14905 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
14906 if (!proj || proj === '__all__') proj = 'All projects';
14907 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
14908 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
14909 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
14910 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
14911 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
14912 }}
14913
14914 function exportTmXLSX() {{
14915 var D = currentLangTests;
14916 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
14917 var t = tmExportMeta();
14918 function s2b(s) {{ return new TextEncoder().encode(s); }}
14919 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
14920 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; }}
14921 function crc32(d) {{
14922 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;}}}}
14923 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
14924 }}
14925 // Store all cells as strings so Excel left-aligns uniformly.
14926 function cs(addr, val, bold) {{
14927 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
14928 }}
14929 // Build an Excel Table XML definition for a given sheet range and columns.
14930 function makeTableXml(tblId, name, ref, cols) {{
14931 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
14932 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14933 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
14934 x+='<autoFilter ref="'+ref+'"/>';
14935 x+='<tableColumns count="'+cols.length+'">';
14936 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
14937 x+='</tableColumns>';
14938 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
14939 return x+'</table>';
14940 }}
14941 // Worksheet XML with optional Excel Table part reference.
14942 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
14943 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14944 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
14945 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
14946 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
14947 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
14948 rows.forEach(function(row,ri){{var rn=ri+2;x+='<row r="'+rn+'">';row.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,false);}});x+='</row>';}});
14949 if(totRow){{var rn=rows.length+2;x+='<row r="'+rn+'">';totRow.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,true);}});x+='</row>';}}
14950 x+='</sheetData>';
14951 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
14952 return x+'</worksheet>';
14953 }}
14954
14955 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
14956 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
14957 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
14958 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
14959 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
14960 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
14961
14962 // Sheet 1: Summary
14963 var sumHdr=['Metric','Value'];
14964 var sumRows=[
14965 ['Project / Scope', t.proj],
14966 ['Export Date', t.full],
14967 ['Test Functions', Number(totTests).toLocaleString()],
14968 ['Assertions', Number(totAssert).toLocaleString()],
14969 ['Test Suites', Number(totSuites).toLocaleString()],
14970 ['Languages with Tests', String(D.length)],
14971 ['Total Code Lines', Number(totCode).toLocaleString()],
14972 ['Average Density (per 1K)', String(avgDensity)],
14973 ];
14974 var sumRef='A1:B'+(sumRows.length+1);
14975 var sumTblXml=makeTableXml(1,'Summary',sumRef,sumHdr);
14976 var sumSheetXml=buildSheet(sumHdr,sumRows,null,[28,22],'rId1');
14977
14978 // Sheet 2: Language Breakdown
14979 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
14980 var langRows=D.map(function(d){{return[d.lang,Number(d.tests).toLocaleString(),Number(d.assertions||0).toLocaleString(),Number(d.suites||0).toLocaleString(),Number(d.code).toLocaleString(),Number(d.files).toLocaleString(),Number(d.density).toFixed(2)];}});
14981 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
14982 // Table covers header + data only (TOTAL row excluded from table, sits just below)
14983 var langDataRows=langRows.length;
14984 var langTblRef='A1:G'+(langDataRows+1);
14985 var langTblXml=makeTableXml(2,'LangBreakdown',langTblRef,langHdr);
14986 var langSheetXml=buildSheet(langHdr,langRows,totRow,[22,15,15,15,15,12,15],'rId1');
14987
14988 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>';
14989 var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/tables/table2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
14990 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>';
14991 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
14992 var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="Language Breakdown" sheetId="2" r:id="rId2"/></sheets></workbook>';
14993 var sh1rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/></Relationships>';
14994 var sh2rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table2.xml"/></Relationships>';
14995 var files=[
14996 {{name:'[Content_Types].xml',data:s2b(ct)}},
14997 {{name:'_rels/.rels',data:s2b(dotrels)}},
14998 {{name:'xl/workbook.xml',data:s2b(wbx)}},
14999 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15000 {{name:'xl/styles.xml',data:s2b(styl)}},
15001 {{name:'xl/worksheets/sheet1.xml',data:s2b(sumSheetXml)}},
15002 {{name:'xl/worksheets/sheet2.xml',data:s2b(langSheetXml)}},
15003 {{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b(sh1rels)}},
15004 {{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b(sh2rels)}},
15005 {{name:'xl/tables/table1.xml',data:s2b(sumTblXml)}},
15006 {{name:'xl/tables/table2.xml',data:s2b(langTblXml)}},
15007 ];
15008 var parts=[],offsets=[],total=0;
15009 files.forEach(function(f){{offsets.push(total);var nb=s2b(f.name),crc=crc32(f.data);var h=new DataView(new ArrayBuffer(30+nb.length));h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);h.setUint16(26,nb.length,true);h.setUint16(28,0,true);for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);parts.push(new Uint8Array(h.buffer));parts.push(f.data);total+=30+nb.length+f.data.length;}});
15010 var cdStart=total;files.forEach(function(f,fi){{var nb=s2b(f.name),crc=crc32(f.data);var cd=new DataView(new ArrayBuffer(46+nb.length));cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;}});
15011 var cdSz=total-cdStart;var eocd=new DataView(new ArrayBuffer(22));eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);parts.push(new Uint8Array(eocd.buffer));
15012 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);var out=new Uint8Array(sz);var off=0;parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15013 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15014 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15015 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15016 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15017 }}
15018
15019 function exportTmPNG() {{
15020 // Map canvas IDs to display titles
15021 var CHART_TITLES = {{
15022 'canvas-trend': 'TEST COUNT TREND',
15023 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15024 'canvas-density': 'TEST DENSITY (PER 1,000 CODE LINES)',
15025 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15026 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15027 'canvas-files': 'TEST FILES BREAKDOWN',
15028 'canvas-composition': 'TEST COMPOSITION'
15029 }};
15030 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15031 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15032 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15033 var t=tmExportMeta();
15034 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15035 var trendCanvas=document.getElementById('canvas-trend');
15036 var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15037 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15038 var TOTAL_W=COLW*2+GAP;
15039 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15040 TREND_H=Math.min(Math.max(200,TREND_H),340);
15041 // Per-row chart heights (2-col grid)
15042 var gridRows=Math.ceil(gridCanvases.length/2);
15043 var rowHeights=[];
15044 for(var ri=0;ri<gridRows;ri++){{
15045 var rh=240;
15046 for(var ci=0;ci<2;ci++){{
15047 var cv=gridCanvases[ri*2+ci];
15048 if(cv&&cv.width>0){{
15049 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15050 rh=Math.max(rh,Math.min(420,nat));
15051 }}
15052 }}
15053 rowHeights.push(rh);
15054 }}
15055 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15056 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15057 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15058 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15059 var ctx=out.getContext('2d');
15060 var cs2=getComputedStyle(document.body);
15061 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15062 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15063 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15064
15065 // Background
15066 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15067
15068 // Orange header block
15069 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15070 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15071 ctx.fillText('Test Metrics — '+t.proj,22,42);
15072 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15073 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15074 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15075
15076 // Helper: draw a section title label
15077 function drawTitle(label, x, y, w) {{
15078 ctx.save();
15079 ctx.fillStyle=oxide;
15080 ctx.font='700 11px '+TM_FONT;
15081 ctx.textBaseline='middle';
15082 ctx.textAlign='left';
15083 ctx.letterSpacing='0.07em';
15084 ctx.fillText(label, x+2, y+TITLE_H/2);
15085 // Underline
15086 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15087 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15088 ctx.globalAlpha=1;
15089 ctx.restore();
15090 }}
15091
15092 var yOff=HEADER_H;
15093
15094 // Trend chart (full width)
15095 if(hasTrend){{
15096 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15097 yOff+=TITLE_H;
15098 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15099 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15100 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15101 ctx.drawImage(surf,0,yOff);
15102 yOff+=TREND_H+ROW_PAD;
15103 }}
15104
15105 // Grid charts (2-col), each cell gets title + chart
15106 for(var gi=0;gi<gridRows;gi++){{
15107 var rh2=rowHeights[gi];
15108 // Draw row titles and charts
15109 for(var gci=0;gci<2;gci++){{
15110 var idx2=gi*2+gci;
15111 if(idx2>=gridCanvases.length)continue;
15112 var gcv=gridCanvases[idx2];
15113 var gx=gci*(COLW+GAP);
15114 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15115 }}
15116 yOff+=TITLE_H;
15117 for(var gci2=0;gci2<2;gci2++){{
15118 var idx3=gi*2+gci2;
15119 if(idx3>=gridCanvases.length)continue;
15120 var gcv2=gridCanvases[idx3];
15121 var gx2=gci2*(COLW+GAP);
15122 var natW=gcv2.width,natH=gcv2.height;
15123 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15124 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15125 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15126 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15127 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15128 ctx.drawImage(surf2,gx2,yOff);
15129 }}
15130 yOff+=rh2+ROW_PAD;
15131 }}
15132
15133 // Dark footer
15134 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15135 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15136 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15137
15138 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15139 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15140 }}
15141
15142 function exportTmPDF() {{
15143 var D=currentLangTests;
15144 var t=tmExportMeta();
15145 var strips=document.querySelectorAll('.summary-strip');
15146 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15147 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15148 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15149 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15150 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15151 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15152 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15153 var rows='';
15154 (D||[]).forEach(function(d){{
15155 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15156 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15157 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15158 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15159 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15160 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15161 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15162 }});
15163 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15164 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15165 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15166 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15167 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15168 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15169 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15170 var tableHtml='<table><thead><tr><th>Language</th><th class="n">Test Fns</th><th class="n">Assertions</th><th class="n">Suites</th><th class="n">Code Lines</th><th class="n">Files</th><th class="n">Density/1K</th></tr></thead><tbody>'+rows+totRow+'</tbody></table>';
15171 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15172 +'html,body{{height:100%;margin:0;}}'
15173 +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;display:flex;flex-direction:column;min-height:100vh;}}'
15174 +'.rep-header{{background:#C45C10;color:#fff;padding:18px 32px 16px;display:flex;justify-content:space-between;align-items:flex-start;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15175 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15176 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15177 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15178 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15179 +'.rep-body{{padding:20px 32px;flex:1;}}'
15180 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15181 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15182 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15183 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15184 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15185 +'.section-hdr{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:#C45C10;margin:16px 0 8px;border-bottom:2px solid #C45C10;padding-bottom:4px;}}'
15186 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15187 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15188 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15189 +'.n{{text-align:right;}}'
15190 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15191 +'.rep-footer{{background:#43342d;color:rgba(255,255,255,0.75);padding:10px 32px;font-size:10px;text-align:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15192 +'</style>';
15193 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15194 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
15195 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15196 +'<div class="rep-body">'+statsHtml
15197 +'<div class="section-hdr">Language Breakdown</div>'
15198 +tableHtml+'</div>'
15199 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15200 +'</body></html>';
15201 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15202 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:document.getElementById('tm-export-pdf-btn')}});
15203 }}
15204
15205 (function() {{
15206 var xBtn=document.getElementById('tm-export-xlsx-btn');
15207 var pngBtn=document.getElementById('tm-export-png-btn');
15208 var pdfBtn=document.getElementById('tm-export-pdf-btn');
15209 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15210 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15211 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15212 }})();
15213
15214 applyScope();
15215 }})();
15216 </script>
15217 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
15218 {toast_assets}
15219</body>
15220</html>"#,
15221 );
15222 (
15223 [(axum::http::header::CACHE_CONTROL, "no-store")],
15224 Html(html),
15225 )
15226 .into_response()
15227}
15228
15229#[derive(Deserialize)]
15236struct EmbedQuery {
15237 run_id: Option<String>,
15238 theme: Option<String>,
15239}
15240
15241async fn embed_handler(
15242 State(state): State<AppState>,
15243 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15244 Query(query): Query<EmbedQuery>,
15245) -> Response {
15246 let entry = {
15247 let reg = state.registry.lock().await;
15248 query.run_id.as_ref().map_or_else(
15249 || reg.entries.first().cloned(),
15250 |id| reg.find_by_run_id(id).cloned(),
15251 )
15252 };
15253
15254 let Some(entry) = entry else {
15255 return Html(
15256 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15257 .to_string(),
15258 )
15259 .into_response();
15260 };
15261
15262 let dark = query.theme.as_deref() == Some("dark");
15263 let languages: Vec<(String, u64, u64)> = entry
15264 .json_path
15265 .as_ref()
15266 .and_then(|p| read_json(p).ok())
15267 .map(|run| {
15268 run.totals_by_language
15269 .iter()
15270 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15271 .collect()
15272 })
15273 .unwrap_or_default();
15274
15275 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15276}
15277
15278fn render_embed_widget(
15279 entry: &RegistryEntry,
15280 languages: &[(String, u64, u64)],
15281 dark: bool,
15282 csp_nonce: &str,
15283) -> String {
15284 let s = &entry.summary;
15285 let total = s.code_lines + s.comment_lines + s.blank_lines;
15286 let code_pct = s
15287 .code_lines
15288 .checked_mul(100)
15289 .and_then(|n| n.checked_div(total))
15290 .unwrap_or(0);
15291
15292 let (bg, fg, surface, muted, border) = if dark {
15293 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15294 } else {
15295 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15296 };
15297
15298 let mut lang_rows = String::new();
15299 for (name, files, code) in languages {
15300 write!(
15301 lang_rows,
15302 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15303 escape_html(name),
15304 format_number(*files),
15305 format_number(*code),
15306 )
15307 .ok();
15308 }
15309
15310 let lang_table = if lang_rows.is_empty() {
15311 String::new()
15312 } else {
15313 format!(
15314 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15315 )
15316 };
15317
15318 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15319 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15320 let project_esc = escape_html(&entry.project_label);
15321 let code_lines = format_number(s.code_lines);
15322 let comment_lines = format_number(s.comment_lines);
15323 let files = format_number(s.files_analyzed);
15324 let code_raw = s.code_lines;
15325 let comment_raw = s.comment_lines;
15326 let blank_raw = s.blank_lines;
15327
15328 format!(
15329 r#"<!doctype html>
15330<html lang="en">
15331<head>
15332 <meta charset="utf-8">
15333 <meta name="viewport" content="width=device-width,initial-scale=1">
15334 <title>OxideSLOC — {project_esc}</title>
15335 <script src="/static/chart.js"></script>
15336 <style nonce="{csp_nonce}">
15337 *{{box-sizing:border-box;margin:0;padding:0}}
15338 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15339 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15340 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15341 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15342 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15343 .card .v{{font-size:18px;font-weight:700}}
15344 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15345 .row{{display:flex;gap:12px;align-items:flex-start}}
15346 .pie{{width:120px;height:120px;flex-shrink:0}}
15347 .lt{{border-collapse:collapse;width:100%;flex:1}}
15348 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15349 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15350 .n{{text-align:right}}
15351 .footer{{margin-top:10px;color:{muted};font-size:10px}}
15352 </style>
15353</head>
15354<body>
15355 <h2>{project_esc}</h2>
15356 <div class="sub">{timestamp} · run {run_short}</div>
15357 <div class="cards">
15358 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15359 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15360 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15361 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15362 </div>
15363 <div class="row">
15364 <canvas class="pie" id="c"></canvas>
15365 {lang_table}
15366 </div>
15367 <div class="footer">oxide-sloc</div>
15368 <script nonce="{csp_nonce}">
15369 new Chart(document.getElementById('c'),{{
15370 type:'doughnut',
15371 data:{{
15372 labels:['Code','Comments','Blank'],
15373 datasets:[{{
15374 data:[{code_raw},{comment_raw},{blank_raw}],
15375 backgroundColor:['#4a78ee','#b35428','#aaa'],
15376 borderWidth:0
15377 }}]
15378 }},
15379 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15380 }});
15381 </script>
15382</body>
15383</html>"#
15384 )
15385}
15386
15387fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15392 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15393 OnceLock::new();
15394 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15395 let mut guard = map
15396 .lock()
15397 .unwrap_or_else(std::sync::PoisonError::into_inner);
15398 guard
15399 .entry(dir.to_path_buf())
15400 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15401 .clone()
15402}
15403
15404#[allow(clippy::too_many_lines)]
15405fn persist_run_artifacts(
15406 run: &sloc_core::AnalysisRun,
15407 report_html: &str,
15408 run_dir: &Path,
15409 report_title: &str,
15410 file_stem: &str,
15411 result_context: RunResultContext,
15412) -> Result<(RunArtifacts, PendingPdf)> {
15413 let dir_lock = output_dir_lock(run_dir);
15416 let _dir_guard = dir_lock
15417 .lock()
15418 .unwrap_or_else(std::sync::PoisonError::into_inner);
15419
15420 let html_dir = run_dir.join("html");
15422 let pdf_dir = run_dir.join("pdf");
15423 let excel_dir = run_dir.join("excel");
15424 let json_dir = run_dir.join("json");
15425 let submodules_dir = run_dir.join("submodules");
15426 for dir in &[
15427 run_dir,
15428 &html_dir,
15429 &pdf_dir,
15430 &excel_dir,
15431 &json_dir,
15432 &submodules_dir,
15433 ] {
15434 fs::create_dir_all(dir)
15435 .with_context(|| format!("failed to create directory {}", dir.display()))?;
15436 }
15437
15438 let html_path = {
15440 let path = html_dir.join(format!("report_{file_stem}.html"));
15441 fs::write(&path, report_html)
15442 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15443 Some(path)
15444 };
15445
15446 let json_path = {
15448 let path = json_dir.join(format!("result_{file_stem}.json"));
15449 let json = serde_json::to_string_pretty(run)
15450 .context("failed to serialize analysis run to JSON")?;
15451 fs::write(&path, json)
15452 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15453 Some(path)
15454 };
15455
15456 let (pdf_path, pending_pdf) = {
15458 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15459 match write_pdf_from_run(run, &pdf_dest) {
15460 Ok(()) => {
15461 eprintln!(
15462 "[oxide-sloc][pdf] native PDF written to {}",
15463 pdf_dest.display()
15464 );
15465 (Some(pdf_dest), None)
15466 }
15467 Err(native_err) => {
15468 eprintln!(
15469 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15470 );
15471 let source_html_path = html_path
15472 .as_ref()
15473 .expect("html_path always Some here")
15474 .clone();
15475 let pending = Some((source_html_path, pdf_dest.clone(), false));
15476 (Some(pdf_dest), pending)
15477 }
15478 }
15479 };
15480
15481 let csv_path = {
15483 let path = excel_dir.join(format!("report_{file_stem}.csv"));
15484 if let Err(e) = sloc_report::write_csv(run, &path) {
15485 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15486 None
15487 } else {
15488 Some(path)
15489 }
15490 };
15491
15492 let xlsx_path = {
15493 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15494 if let Err(e) = sloc_report::write_xlsx(run, &path) {
15495 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15496 None
15497 } else {
15498 Some(path)
15499 }
15500 };
15501
15502 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15504
15505 if run.effective_configuration.discovery.submodule_breakdown {
15507 let run_id = &run.tool.run_id;
15508 for s in &run.submodule_summaries {
15509 build_submodule_row(s, run, run_id, run_dir);
15510 }
15511 }
15512
15513 generate_offline_index(
15515 run,
15516 run_dir,
15517 file_stem,
15518 html_path.as_deref(),
15519 pdf_path.as_deref(),
15520 json_path.as_deref(),
15521 scan_config_path.as_deref(),
15522 &result_context,
15523 );
15524
15525 Ok((
15526 RunArtifacts {
15527 output_dir: run_dir.to_path_buf(),
15528 html_path,
15529 pdf_path,
15530 json_path,
15531 csv_path,
15532 xlsx_path,
15533 scan_config_path,
15534 report_title: report_title.to_string(),
15535 result_context,
15536 },
15537 pending_pdf,
15538 ))
15539}
15540
15541#[allow(clippy::too_many_arguments)]
15544#[allow(clippy::too_many_lines)]
15545#[allow(clippy::similar_names)]
15546fn generate_offline_index(
15547 run: &sloc_core::AnalysisRun,
15548 run_dir: &Path,
15549 file_stem: &str,
15550 html_path: Option<&Path>,
15551 pdf_path: Option<&Path>,
15552 json_path: Option<&Path>,
15553 scan_config_path: Option<&Path>,
15554 result_context: &RunResultContext,
15555) {
15556 let prev_entry = &result_context.prev_entry;
15557 let prev_scan_count = result_context.prev_scan_count;
15558 let project_path = &result_context.project_path;
15559
15560 let scan_delta = prev_entry.as_ref().and_then(|prev| {
15561 prev.json_path
15562 .as_ref()
15563 .and_then(|p| read_json(p).ok())
15564 .map(|prev_run| compute_delta(&prev_run, run))
15565 });
15566
15567 let files_analyzed = run.per_file_records.len() as u64;
15568 let files_skipped = run.skipped_file_records.len() as u64;
15569 let totals = sum_lang_totals(run);
15570
15571 let DeltaFields {
15572 prev_fa_str,
15573 prev_fs_str,
15574 prev_pl_str,
15575 prev_cl_str,
15576 prev_cml_str,
15577 prev_bl_str,
15578 delta_fa_str,
15579 delta_fa_class,
15580 delta_fs_str,
15581 delta_fs_class,
15582 delta_pl_str,
15583 delta_pl_class,
15584 delta_cl_str,
15585 delta_cl_class,
15586 delta_cml_str,
15587 delta_cml_class,
15588 delta_bl_str,
15589 delta_bl_class,
15590 delta_lines_added,
15591 delta_lines_removed,
15592 delta_lines_net_str,
15593 delta_lines_net_class,
15594 } = compute_delta_fields(
15595 prev_entry.as_ref(),
15596 &totals,
15597 files_analyzed,
15598 files_skipped,
15599 scan_delta.as_ref(),
15600 );
15601
15602 let git_commit_url = git_commit_url_for(run);
15603 let git_branch_url = git_branch_url_for(run);
15604 let scan_performed_by = scan_performed_by(run);
15605
15606 let make_rel = |p: Option<&Path>| -> Option<String> {
15608 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15609 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15610 };
15611
15612 let run_id = &run.tool.run_id;
15613
15614 let submodule_rows: Vec<SubmoduleRow> = run
15616 .submodule_summaries
15617 .iter()
15618 .map(|s| {
15619 let safe = sanitize_project_label(&s.name);
15620 let key = format!("sub_{safe}");
15621 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15622 SubmoduleRow {
15623 name: s.name.clone(),
15624 relative_path: s.relative_path.clone(),
15625 files_analyzed: s.files_analyzed,
15626 code_lines: s.code_lines,
15627 comment_lines: s.comment_lines,
15628 blank_lines: s.blank_lines,
15629 total_physical_lines: s.total_physical_lines,
15630 html_url: if sub_path.exists() {
15631 Some(format!("submodules/{key}.html"))
15632 } else {
15633 None
15634 },
15635 }
15636 })
15637 .collect();
15638
15639 let lang_chart_json = build_lang_chart_json(run);
15640
15641 let scan_config_rel =
15642 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15643
15644 let template = ResultTemplate {
15645 version: env!("CARGO_PKG_VERSION"),
15646 report_title: run.effective_configuration.reporting.report_title.clone(),
15647 project_path: project_path.clone(),
15648 output_dir: display_path(run_dir),
15649 run_id: run_id.clone(),
15650 run_id_short: run_id
15651 .split('-')
15652 .next_back()
15653 .unwrap_or(run_id)
15654 .chars()
15655 .take(7)
15656 .collect(),
15657 files_analyzed,
15658 files_skipped,
15659 physical_lines: totals.physical_lines,
15660 code_lines: totals.code_lines,
15661 comment_lines: totals.comment_lines,
15662 blank_lines: totals.blank_lines,
15663 mixed_lines: totals.mixed_lines,
15664 functions: totals.functions,
15665 classes: totals.classes,
15666 variables: totals.variables,
15667 imports: totals.imports,
15668 html_url: make_rel(html_path),
15669 pdf_url: make_rel(pdf_path),
15670 json_url: make_rel(json_path),
15671 html_download_url: make_rel(html_path),
15672 pdf_download_url: make_rel(pdf_path),
15673 json_download_url: make_rel(json_path),
15674 html_path: html_path.map(display_path),
15675 json_path: json_path.map(display_path),
15676 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15677 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15678 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15679 prev_fa_str,
15680 prev_fs_str,
15681 prev_pl_str,
15682 prev_cl_str,
15683 prev_cml_str,
15684 prev_bl_str,
15685 delta_fa_str,
15686 delta_fa_class,
15687 delta_fs_str,
15688 delta_fs_class,
15689 delta_pl_str,
15690 delta_pl_class,
15691 delta_cl_str,
15692 delta_cl_class,
15693 delta_cml_str,
15694 delta_cml_class,
15695 delta_bl_str,
15696 delta_bl_class,
15697 delta_lines_added,
15698 delta_lines_removed,
15699 delta_lines_net_str,
15700 delta_lines_net_class,
15701 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15702 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15703 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15704 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15705 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15706 git_branch: run.git_branch.clone(),
15707 git_branch_url,
15708 git_commit: run.git_commit_short.clone(),
15709 git_commit_long: run.git_commit_long.clone(),
15710 git_author: run.git_commit_author.clone(),
15711 git_commit_url,
15712 scan_performed_by,
15713 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
15714 os_display: format!(
15715 "{} / {}",
15716 run.environment.operating_system, run.environment.architecture
15717 ),
15718 test_count: run.summary_totals.test_count,
15719 test_assertion_count: run.summary_totals.test_assertion_count,
15720 current_scan_number: prev_scan_count + 1,
15721 prev_scan_count,
15722 submodule_rows,
15723 pdf_generating: false,
15724 scan_config_url: scan_config_rel,
15725 lang_chart_json,
15726 scatter_chart_json: build_scatter_chart_json(run),
15727 semantic_chart_json: build_semantic_chart_json(run),
15728 submodule_chart_json: build_submodule_chart_json(run),
15729 has_submodule_data: !run.submodule_summaries.is_empty(),
15730 has_semantic_data: run
15731 .totals_by_language
15732 .iter()
15733 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
15734 csp_nonce: String::new(),
15735 confluence_configured: false,
15736 server_mode: false,
15737 report_header_footer: run
15738 .effective_configuration
15739 .reporting
15740 .report_header_footer
15741 .clone(),
15742 is_offline: true,
15743 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
15744 lsloc: run.summary_totals.lsloc,
15745 uloc: run.uloc,
15746 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
15747 duplicate_group_count: run.duplicate_groups.len(),
15748 has_cocomo: run.cocomo.is_some(),
15749 cocomo_effort_str: run
15750 .cocomo
15751 .as_ref()
15752 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
15753 cocomo_duration_str: run
15754 .cocomo
15755 .as_ref()
15756 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
15757 cocomo_staff_str: run
15758 .cocomo
15759 .as_ref()
15760 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
15761 cocomo_ksloc_str: run
15762 .cocomo
15763 .as_ref()
15764 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
15765 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
15766 || "Organic".to_string(),
15767 |c| cocomo_mode_label(c.mode).to_string(),
15768 ),
15769 cocomo_mode_tooltip: run
15770 .cocomo
15771 .as_ref()
15772 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
15773 complexity_alert: 0,
15774 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
15775 cov_line_pct: cov_pct_str(
15776 run.summary_totals.coverage_lines_hit,
15777 run.summary_totals.coverage_lines_found,
15778 ),
15779 cov_fn_pct: cov_pct_str(
15780 run.summary_totals.coverage_functions_hit,
15781 run.summary_totals.coverage_functions_found,
15782 ),
15783 cov_branch_pct: cov_pct_str(
15784 run.summary_totals.coverage_branches_hit,
15785 run.summary_totals.coverage_branches_found,
15786 ),
15787 cov_lines_summary: cov_lines_summary_str(
15788 run.summary_totals.coverage_lines_hit,
15789 run.summary_totals.coverage_lines_found,
15790 ),
15791 };
15792
15793 if let Ok(html) = template.render() {
15794 let html = inline_offline_logos(&html);
15798 let index_path = run_dir.join("index.html");
15799 if let Err(e) = fs::write(&index_path, html) {
15800 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
15801 }
15802 }
15803}
15804
15805fn inline_offline_logos(html: &str) -> String {
15809 use base64::Engine;
15810 let text_uri = format!(
15811 "data:image/png;base64,{}",
15812 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
15813 );
15814 let small_uri = format!(
15815 "data:image/png;base64,{}",
15816 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
15817 );
15818 html.replace("/images/logo/logo-text.png", &text_uri)
15819 .replace("/images/logo/small-logo.png", &small_uri)
15820}
15821
15822fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
15825 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
15827 return Some(found);
15828 }
15829 find_scan_config_in_dir_flat(dir)
15831}
15832
15833fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
15834 let exact = dir.join("scan-config.json");
15835 if exact.exists() {
15836 return Some(exact);
15837 }
15838 fs::read_dir(dir).ok().and_then(|entries| {
15839 entries
15840 .filter_map(std::result::Result::ok)
15841 .find(|e| {
15842 let name = e.file_name();
15843 let name = name.to_string_lossy();
15844 name.starts_with("scan-config") && name.ends_with(".json")
15845 })
15846 .map(|e| e.path())
15847 })
15848}
15849
15850#[derive(Deserialize)]
15855struct ExportPdfRequest {
15856 html: String,
15857 #[serde(default)]
15858 filename: Option<String>,
15859}
15860
15861async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
15862 let html_content = body.html;
15863 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
15864 if html_content.is_empty() {
15865 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
15866 }
15867 let tmp_dir = std::env::temp_dir();
15869 let html_path = tmp_dir.join(format!(
15870 "sloc-export-{}.html",
15871 uuid::Uuid::new_v4().simple()
15872 ));
15873 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
15874 if let Err(e) = std::fs::write(&html_path, &html_content) {
15875 return (
15876 StatusCode::INTERNAL_SERVER_ERROR,
15877 format!("Failed to write temp HTML: {e}"),
15878 )
15879 .into_response();
15880 }
15881 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
15882 let _ = std::fs::remove_file(&html_path);
15883 if let Err(e) = pdf_result {
15884 let _ = std::fs::remove_file(&pdf_path);
15885 return (
15886 StatusCode::INTERNAL_SERVER_ERROR,
15887 format!("PDF generation failed: {e}"),
15888 )
15889 .into_response();
15890 }
15891 let pdf_bytes = match std::fs::read(&pdf_path) {
15892 Ok(b) => b,
15893 Err(e) => {
15894 let _ = std::fs::remove_file(&pdf_path);
15895 return (
15896 StatusCode::INTERNAL_SERVER_ERROR,
15897 format!("Failed to read PDF: {e}"),
15898 )
15899 .into_response();
15900 }
15901 };
15902 let _ = std::fs::remove_file(&pdf_path);
15903 let safe_name: String = filename
15904 .chars()
15905 .map(|c| {
15906 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
15907 c
15908 } else {
15909 '_'
15910 }
15911 })
15912 .collect();
15913 let disposition = format!("attachment; filename=\"{safe_name}\"");
15914 (
15915 [
15916 (header::CONTENT_TYPE, "application/pdf".to_string()),
15917 (header::CONTENT_DISPOSITION, disposition),
15918 ],
15919 pdf_bytes,
15920 )
15921 .into_response()
15922}
15923
15924async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
15925 let toml_str = match toml::to_string_pretty(&state.base_config) {
15926 Ok(s) => s,
15927 Err(e) => {
15928 return (
15929 StatusCode::INTERNAL_SERVER_ERROR,
15930 format!("serialization error: {e}"),
15931 )
15932 .into_response();
15933 }
15934 };
15935 (
15936 [
15937 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
15938 (
15939 header::CONTENT_DISPOSITION,
15940 "attachment; filename=\".oxide-sloc.toml\"",
15941 ),
15942 ],
15943 toml_str,
15944 )
15945 .into_response()
15946}
15947
15948#[derive(Serialize)]
15949struct OkResponse {
15950 ok: bool,
15951}
15952
15953#[derive(Serialize)]
15954struct SaveProfileResponse {
15955 ok: bool,
15956 id: String,
15957}
15958
15959#[derive(Serialize)]
15960struct ProfileListResponse {
15961 profiles: Vec<ScanProfile>,
15962}
15963
15964#[derive(Serialize)]
15965struct ImportConfigResponse {
15966 ok: bool,
15967 config: sloc_config::AppConfig,
15968}
15969
15970#[derive(Deserialize)]
15971struct ImportConfigBody {
15972 toml: String,
15973}
15974
15975async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
15976 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
15977 Ok(config) => {
15978 if let Err(e) = config.validate() {
15979 return error::unprocessable_entity(&e.to_string());
15980 }
15981 Json(ImportConfigResponse { ok: true, config }).into_response()
15982 }
15983 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
15984 }
15985}
15986
15987async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
15990 let store = state.scan_profiles.lock().await;
15991 Json(ProfileListResponse {
15992 profiles: store.profiles.clone(),
15993 })
15994}
15995
15996#[derive(Deserialize)]
15997struct SaveScanProfileBody {
15998 name: String,
15999 params: serde_json::Value,
16000}
16001
16002async fn api_save_scan_profile(
16003 State(state): State<AppState>,
16004 Json(body): Json<SaveScanProfileBody>,
16005) -> impl IntoResponse {
16006 if body.name.trim().is_empty() {
16007 return error::bad_request("name must not be empty");
16008 }
16009
16010 let id = uuid::Uuid::new_v4().to_string();
16011 let profile = ScanProfile {
16012 id: id.clone(),
16013 name: body.name.trim().to_string(),
16014 created_at: chrono::Utc::now().to_rfc3339(),
16015 params: body.params,
16016 };
16017
16018 let mut store = state.scan_profiles.lock().await;
16019 store.profiles.push(profile);
16020 if let Err(e) = store.save(&state.scan_profiles_path) {
16021 tracing::warn!("failed to persist scan profiles: {e}");
16022 }
16023 drop(store);
16024
16025 (
16026 StatusCode::CREATED,
16027 Json(SaveProfileResponse { ok: true, id }),
16028 )
16029 .into_response()
16030}
16031
16032async fn api_delete_scan_profile(
16033 State(state): State<AppState>,
16034 AxumPath(id): AxumPath<String>,
16035) -> impl IntoResponse {
16036 let mut store = state.scan_profiles.lock().await;
16037 let before = store.profiles.len();
16038 store.profiles.retain(|p| p.id != id);
16039 if store.profiles.len() == before {
16040 drop(store);
16041 return error::not_found("profile not found");
16042 }
16043 if let Err(e) = store.save(&state.scan_profiles_path) {
16044 tracing::warn!("failed to persist scan profiles: {e}");
16045 }
16046 drop(store);
16047 Json(OkResponse { ok: true }).into_response()
16048}
16049
16050fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16051 let value = raw.unwrap_or("out/web").trim();
16052 let path = if value.is_empty() {
16053 PathBuf::from("out/web")
16054 } else {
16055 PathBuf::from(value)
16056 };
16057
16058 if path.is_absolute() {
16059 path
16060 } else {
16061 workspace_root().join(path)
16062 }
16063}
16064
16065fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16067 std::env::var("SLOC_GIT_CLONES_DIR")
16068 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16069}
16070
16071pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16074 let safe: String = repo_url
16075 .chars()
16076 .map(|c| {
16077 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16078 c
16079 } else {
16080 '_'
16081 }
16082 })
16083 .take(80)
16084 .collect();
16085 clones_dir.join(safe)
16086}
16087
16088pub(crate) fn scan_path_to_artifacts(
16091 scan_path: &Path,
16092 base_config: &AppConfig,
16093 label: &str,
16094) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16095 let mut config = base_config.clone();
16096 config.discovery.root_paths = vec![scan_path.to_path_buf()];
16097 label.clone_into(&mut config.reporting.report_title);
16098 let run = analyze(&config, "git", None, None)?;
16099 let html = render_html(&run)?;
16100 let run_id = run.tool.run_id.clone();
16101 let project_label = sanitize_project_label(label);
16102 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16103 let file_stem = {
16104 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16105 if commit.is_empty() {
16106 project_label
16107 } else {
16108 format!("{project_label}_{commit}")
16109 }
16110 };
16111 let (artifacts, _pending_pdf) = persist_run_artifacts(
16112 &run,
16113 &html,
16114 &output_dir,
16115 label,
16116 &file_stem,
16117 RunResultContext::default(),
16118 )?;
16119 Ok((run_id, artifacts, run))
16120}
16121
16122async fn restart_poll_schedules(state: &AppState) {
16124 let store = state.schedules.lock().await;
16125 let poll_schedules: Vec<_> = store
16126 .schedules
16127 .iter()
16128 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16129 .cloned()
16130 .collect();
16131 drop(store);
16132 for schedule in poll_schedules {
16133 let interval = schedule.interval_secs.unwrap_or(300);
16134 let st = state.clone();
16135 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16136 }
16137}
16138
16139async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16145 if state.tls_enabled {
16146 return;
16147 }
16148 let store = state.schedules.lock().await;
16149 let has_gitlab_webhook = store.schedules.iter().any(|s| {
16150 s.kind == sloc_git::ScanScheduleKind::Webhook
16151 && s.provider == sloc_git::ScanScheduleProvider::GitLab
16152 });
16153 drop(store);
16154 if has_gitlab_webhook {
16155 tracing::warn!(
16156 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16157 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16158 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16159 proxy so the token is not exposed in cleartext."
16160 );
16161 }
16162}
16163
16164fn split_patterns(raw: Option<&str>) -> Vec<String> {
16165 raw.unwrap_or("")
16166 .lines()
16167 .flat_map(|line| line.split(','))
16168 .map(str::trim)
16169 .filter(|part| !part.is_empty())
16170 .map(ToOwned::to_owned)
16171 .collect()
16172}
16173
16174#[must_use]
16175pub fn build_sub_run(
16176 parent: &AnalysisRun,
16177 sub: &sloc_core::SubmoduleSummary,
16178 parent_path: &str,
16179) -> AnalysisRun {
16180 let sub_files: Vec<_> = parent
16181 .per_file_records
16182 .iter()
16183 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16184 .cloned()
16185 .collect();
16186 let mut config = parent.effective_configuration.clone();
16187 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16188
16189 let mut functions = 0u64;
16191 let mut classes = 0u64;
16192 let mut variables = 0u64;
16193 let mut imports = 0u64;
16194 let mut test_count = 0u64;
16195 let mut test_assertion_count = 0u64;
16196 let mut test_suite_count = 0u64;
16197 let mut mixed_lines_separate = 0u64;
16198 let mut coverage_lines_found = 0u64;
16199 let mut coverage_lines_hit = 0u64;
16200 let mut coverage_functions_found = 0u64;
16201 let mut coverage_functions_hit = 0u64;
16202 let mut coverage_branches_found = 0u64;
16203 let mut coverage_branches_hit = 0u64;
16204 for r in &sub_files {
16205 functions += r.raw_line_categories.functions;
16206 classes += r.raw_line_categories.classes;
16207 variables += r.raw_line_categories.variables;
16208 imports += r.raw_line_categories.imports;
16209 test_count += r.raw_line_categories.test_count;
16210 test_assertion_count += r.raw_line_categories.test_assertion_count;
16211 test_suite_count += r.raw_line_categories.test_suite_count;
16212 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16213 if let Some(cov) = &r.coverage {
16214 coverage_lines_found += u64::from(cov.lines_found);
16215 coverage_lines_hit += u64::from(cov.lines_hit);
16216 coverage_functions_found += u64::from(cov.functions_found);
16217 coverage_functions_hit += u64::from(cov.functions_hit);
16218 coverage_branches_found += u64::from(cov.branches_found);
16219 coverage_branches_hit += u64::from(cov.branches_hit);
16220 }
16221 }
16222
16223 AnalysisRun {
16224 tool: parent.tool.clone(),
16225 environment: parent.environment.clone(),
16226 effective_configuration: config,
16227 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16228 summary_totals: SummaryTotals {
16229 files_considered: sub.files_analyzed,
16230 files_analyzed: sub.files_analyzed,
16231 files_skipped: 0,
16232 total_physical_lines: sub.total_physical_lines,
16233 code_lines: sub.code_lines,
16234 comment_lines: sub.comment_lines,
16235 blank_lines: sub.blank_lines,
16236 mixed_lines_separate,
16237 functions,
16238 classes,
16239 variables,
16240 imports,
16241 test_count,
16242 test_assertion_count,
16243 test_suite_count,
16244 coverage_lines_found,
16245 coverage_lines_hit,
16246 coverage_functions_found,
16247 coverage_functions_hit,
16248 coverage_branches_found,
16249 coverage_branches_hit,
16250 cyclomatic_complexity: 0,
16251 lsloc: None,
16252 },
16253 totals_by_language: sub.language_summaries.clone(),
16254 per_file_records: sub_files,
16255 skipped_file_records: vec![],
16256 warnings: vec![],
16257 submodule_summaries: vec![],
16258 git_commit_short: sub.git_commit_short.clone(),
16259 git_commit_long: sub.git_commit_long.clone(),
16260 git_branch: sub.git_branch.clone(),
16261 git_commit_author: sub.git_commit_author.clone(),
16262 git_commit_date: sub.git_commit_date.clone(),
16263 git_tags: None,
16264 git_nearest_tag: None,
16265 git_remote_url: sub.git_remote_url.clone(),
16266 style_summary: None,
16267 cocomo: None,
16268 uloc: 0,
16269 dryness_pct: None,
16270 duplicate_groups: vec![],
16271 duplicates_excluded: 0,
16272 }
16273}
16274
16275#[must_use]
16276pub fn sanitize_project_label(raw: &str) -> String {
16277 let candidate = raw
16280 .split(['/', '\\'])
16281 .rfind(|s| !s.is_empty())
16282 .unwrap_or("project");
16283
16284 let mut value = String::with_capacity(candidate.len());
16285 for ch in candidate.chars() {
16286 if ch.is_ascii_alphanumeric() {
16287 value.push(ch.to_ascii_lowercase());
16288 } else {
16289 value.push('-');
16290 }
16291 }
16292
16293 let compact = value.trim_matches('-').to_string();
16294 if compact.is_empty() {
16295 "project".to_string()
16296 } else {
16297 compact
16298 }
16299}
16300
16301fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16304 let s = path.to_string_lossy();
16305 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16306 return PathBuf::from(format!(r"\\{rest}"));
16307 }
16308 if let Some(rest) = s.strip_prefix(r"\\?\") {
16309 return PathBuf::from(rest);
16310 }
16311 path
16312}
16313
16314fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16317 let base = if let Some(rest) = remote.strip_prefix("git@") {
16318 let (host, path) = rest.split_once(':')?;
16319 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16320 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16321 remote
16322 .trim_end_matches('/')
16323 .trim_end_matches(".git")
16324 .to_owned()
16325 } else {
16326 return None;
16327 };
16328 let base = base.trim_end_matches('/');
16329 if base.contains("gitlab.com") || base.contains("gitlab.") {
16331 Some(format!("{base}/-/commit/{sha}"))
16332 } else if base.contains("bitbucket.org") {
16333 Some(format!("{base}/commits/{sha}"))
16334 } else {
16335 Some(format!("{base}/commit/{sha}"))
16336 }
16337}
16338
16339fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16342 let base = if let Some(rest) = remote.strip_prefix("git@") {
16343 let (host, path) = rest.split_once(':')?;
16344 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16345 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16346 remote
16347 .trim_end_matches('/')
16348 .trim_end_matches(".git")
16349 .to_owned()
16350 } else {
16351 return None;
16352 };
16353 let base = base.trim_end_matches('/');
16354 if base.contains("gitlab.com") || base.contains("gitlab.") {
16355 Some(format!("{base}/-/tree/{branch}"))
16356 } else {
16357 Some(format!("{base}/tree/{branch}"))
16358 }
16359}
16360
16361fn display_path(path: &Path) -> String {
16362 let s = path.to_string_lossy();
16363 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16368 return format!(r"\\{rest}");
16369 }
16370 if let Some(rest) = s.strip_prefix(r"\\?\") {
16371 return rest.to_owned();
16372 }
16373 s.into_owned()
16374}
16375
16376fn sanitize_path_str(s: &str) -> String {
16377 if let Some(rest) = s.strip_prefix("//?/UNC/") {
16381 return format!("//{rest}");
16382 }
16383 if let Some(rest) = s.strip_prefix("//?/") {
16384 return rest.to_owned();
16385 }
16386 display_path(Path::new(s))
16387}
16388
16389fn workspace_root() -> PathBuf {
16390 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16392 let p = PathBuf::from(root);
16393 if p.is_dir() {
16394 return p;
16395 }
16396 }
16397
16398 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16401}
16402
16403fn make_git_label(repo: &str, ref_name: &str) -> String {
16405 if repo.is_empty() || ref_name.is_empty() {
16406 return String::new();
16407 }
16408 let base = repo
16409 .trim_end_matches('/')
16410 .trim_end_matches(".git")
16411 .rsplit('/')
16412 .next()
16413 .unwrap_or("repo");
16414 let ref_safe: String = ref_name
16415 .chars()
16416 .map(|c| {
16417 if c.is_alphanumeric() || c == '-' || c == '.' {
16418 c
16419 } else {
16420 '_'
16421 }
16422 })
16423 .collect();
16424 format!("{base}_at_{ref_safe}_sloc")
16425}
16426
16427fn desktop_dir() -> PathBuf {
16429 if let Ok(profile) = std::env::var("USERPROFILE") {
16430 let p = PathBuf::from(profile).join("Desktop");
16431 if p.exists() {
16432 return p;
16433 }
16434 }
16435 if let Ok(home) = std::env::var("HOME") {
16436 let p = PathBuf::from(home).join("Desktop");
16437 if p.exists() {
16438 return p;
16439 }
16440 }
16441 workspace_root().join("out").join("web")
16442}
16443
16444fn resolve_input_path(raw: &str) -> PathBuf {
16445 let trimmed = raw.trim();
16446 if trimmed.is_empty() {
16447 return workspace_root().join("samples").join("basic");
16448 }
16449
16450 let candidate = PathBuf::from(trimmed);
16451 let resolved = if candidate.is_absolute() {
16452 candidate
16453 } else {
16454 let rooted = workspace_root().join(&candidate);
16455 if rooted.exists() {
16456 rooted
16457 } else {
16458 workspace_root().join(candidate)
16459 }
16460 };
16461
16462 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16465 PathBuf::from(display_path(&canonical))
16466}
16467
16468fn dir_size_bytes(path: &Path) -> u64 {
16469 let mut total = 0u64;
16470 if let Ok(rd) = fs::read_dir(path) {
16471 for entry in rd.filter_map(Result::ok) {
16472 let p = entry.path();
16473 if p.is_file() {
16474 if let Ok(meta) = p.metadata() {
16475 total += meta.len();
16476 }
16477 } else if p.is_dir() {
16478 total += dir_size_bytes(&p);
16479 }
16480 }
16481 }
16482 total
16483}
16484
16485#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
16487 if bytes >= 1_073_741_824 {
16488 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16489 } else if bytes >= 1_048_576 {
16490 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16491 } else if bytes >= 1_024 {
16492 format!("{:.0} KB", bytes as f64 / 1_024.0)
16493 } else {
16494 format!("{bytes} B")
16495 }
16496}
16497
16498fn render_submodule_chips(
16499 root: &Path,
16500 submodules: &[(String, std::path::PathBuf)],
16501 out: &mut String,
16502) {
16503 use std::fmt::Write as _;
16504 let count = submodules.len();
16505 out.push_str(r#"<div class="submodule-preview-strip">"#);
16506 write!(
16507 out,
16508 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>"#,
16509 if count == 1 { "" } else { "s" }
16510 )
16511 .ok();
16512 out.push_str(r#"<div class="submodule-preview-chips">"#);
16513 for (sub_name, sub_rel_path) in submodules {
16514 let sub_abs = root.join(sub_rel_path);
16515 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16516 let mut sub_stats = PreviewStats::default();
16517 let mut sub_rows: Vec<PreviewRow> = Vec::new();
16518 let mut sub_langs: Vec<&'static str> = Vec::new();
16519 let mut sub_budget = PreviewBudget {
16520 shown: 0,
16521 max_entries: 2000,
16522 max_depth: 9,
16523 };
16524 let mut sub_next_id = 1usize;
16525 let _ = collect_preview_rows(
16526 &sub_abs,
16527 &sub_abs,
16528 0,
16529 None,
16530 &mut sub_next_id,
16531 &mut sub_budget,
16532 &mut sub_stats,
16533 &mut sub_rows,
16534 &mut sub_langs,
16535 &[],
16536 &[],
16537 );
16538 let stats_json = format!(
16539 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16540 sub_stats.directories,
16541 sub_stats.files,
16542 sub_stats.supported,
16543 sub_stats.skipped,
16544 sub_stats.unsupported
16545 );
16546 write!(
16547 out,
16548 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>"#,
16549 escape_html(sub_name),
16550 escape_html(&sub_rel_path.to_string_lossy()),
16551 escape_html(&sub_size),
16552 escape_html(&stats_json),
16553 escape_html(sub_name),
16554 escape_html(&sub_size),
16555 )
16556 .ok();
16557 }
16558 out.push_str(
16559 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
16560 );
16561 out.push_str(r"</div>");
16562}
16563
16564fn render_language_pills_row(languages: &[&str], out: &mut String) {
16565 use std::fmt::Write as _;
16566 if languages.is_empty() {
16567 out.push_str(
16568 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16569 );
16570 return;
16571 }
16572 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16573 for language in languages {
16574 if let Some(icon) = language_icon_file(language) {
16575 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();
16576 } else if let Some(svg) = language_inline_svg(language) {
16577 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();
16578 } else {
16579 write!(
16580 out,
16581 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16582 escape_html(&language.to_ascii_lowercase()),
16583 escape_html(language)
16584 )
16585 .ok();
16586 }
16587 }
16588}
16589
16590#[allow(clippy::too_many_lines)]
16591fn build_preview_html(
16592 root: &Path,
16593 include_patterns: &[String],
16594 exclude_patterns: &[String],
16595) -> Result<String> {
16596 if !root.exists() {
16597 return Ok(format!(
16598 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16599 escape_html(&display_path(root))
16600 ));
16601 }
16602
16603 let _selected = display_path(root);
16604 let mut stats = PreviewStats::default();
16605 let mut rows = Vec::new();
16606 let mut languages = Vec::new();
16607 let mut budget = PreviewBudget {
16608 shown: 0,
16609 max_entries: 600,
16610 max_depth: 9,
16611 };
16612 let mut next_row_id = 1usize;
16613
16614 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16615 || root.to_string_lossy().into_owned(),
16616 std::string::ToString::to_string,
16617 );
16618 let root_modified = root
16619 .metadata()
16620 .ok()
16621 .and_then(|meta| meta.modified().ok())
16622 .map_or_else(|| "-".to_string(), format_system_time);
16623
16624 rows.push(PreviewRow {
16625 row_id: 0,
16626 parent_row_id: None,
16627 depth: 0,
16628 name: format!("{root_name}/"),
16629 kind: PreviewKind::Dir,
16630 is_dir: true,
16631 language: None,
16632 modified: root_modified,
16633 type_label: "Directory".to_string(),
16634 });
16635 collect_preview_rows(
16636 root,
16637 root,
16638 0,
16639 Some(0),
16640 &mut next_row_id,
16641 &mut budget,
16642 &mut stats,
16643 &mut rows,
16644 &mut languages,
16645 include_patterns,
16646 exclude_patterns,
16647 )?;
16648
16649 let root_size = format_dir_size(dir_size_bytes(root));
16650
16651 let mut out = String::new();
16652 write!(
16653 out,
16654 r#"<div class="explorer-wrap" data-project-size="{}">"#,
16655 escape_html(&root_size)
16656 )
16657 .ok();
16658 out.push_str(r#"<div class="explorer-toolbar compact">"#);
16659 out.push_str(r#"<div class="explorer-title-group">"#);
16660 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
16661 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
16662 out.push_str(r"</div></div>");
16663
16664 out.push_str(r#"<div class="scope-stats">"#);
16665 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();
16666 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();
16667 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();
16668 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();
16669 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();
16670 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>"#);
16671 out.push_str(r"</div>");
16672
16673 let submodules = sloc_core::detect_submodules(root);
16674 if !submodules.is_empty() {
16675 render_submodule_chips(root, &submodules, &mut out);
16676 }
16677
16678 out.push_str(r#"<div class="scope-info-row">"#);
16679 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
16680 render_language_pills_row(&languages, &mut out);
16681 out.push_str(r"</div></div>");
16682 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>"#);
16683 out.push_str(r"</div>");
16684
16685 out.push_str(r#"<div class="file-explorer-shell">"#);
16686 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>"#);
16687 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>"#);
16688 out.push_str(r#"<div class="file-explorer-tree">"#);
16689 for row in rows {
16690 let status_label = row.kind.label();
16691 let lang_attr = row.language.unwrap_or("");
16692 let toggle_html = if row.is_dir {
16693 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">\u25be</button>"#
16694 .to_string()
16695 } else {
16696 r#"<span class="tree-bullet">•</span>"#.to_string()
16697 };
16698 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();
16699 }
16700 if budget.shown >= budget.max_entries {
16701 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>"#);
16702 }
16703 out.push_str(r"</div></div></div>");
16704
16705 Ok(out)
16706}
16707
16708#[derive(Default)]
16709struct PreviewStats {
16710 directories: usize,
16711 files: usize,
16712 supported: usize,
16713 skipped: usize,
16714 unsupported: usize,
16715}
16716
16717struct PreviewRow {
16718 row_id: usize,
16719 parent_row_id: Option<usize>,
16720 depth: usize,
16721 name: String,
16722 kind: PreviewKind,
16723 is_dir: bool,
16724 language: Option<&'static str>,
16725 modified: String,
16726 type_label: String,
16727}
16728
16729#[derive(Copy, Clone)]
16730enum PreviewKind {
16731 Dir,
16732 Supported,
16733 Skipped,
16734 Unsupported,
16735}
16736
16737impl PreviewKind {
16738 const fn filter_key(self) -> &'static str {
16739 match self {
16740 Self::Dir => "dir",
16741 Self::Supported => "supported",
16742 Self::Skipped => "skipped",
16743 Self::Unsupported => "unsupported",
16744 }
16745 }
16746
16747 const fn label(self) -> &'static str {
16748 match self {
16749 Self::Dir => "dir",
16750 Self::Supported => "supported",
16751 Self::Skipped => "skipped by policy",
16752 Self::Unsupported => "unsupported",
16753 }
16754 }
16755
16756 const fn badge_class(self) -> &'static str {
16757 match self {
16758 Self::Dir => "badge badge-dir",
16759 Self::Supported => "badge badge-scan",
16760 Self::Skipped => "badge badge-skip",
16761 Self::Unsupported => "badge badge-unsupported",
16762 }
16763 }
16764
16765 const fn node_class(self) -> &'static str {
16766 match self {
16767 Self::Dir => "tree-node-dir",
16768 Self::Supported => "tree-node-supported",
16769 Self::Skipped => "tree-node-skipped",
16770 Self::Unsupported => "tree-node-unsupported",
16771 }
16772 }
16773}
16774
16775struct PreviewBudget {
16776 shown: usize,
16777 max_entries: usize,
16778 max_depth: usize,
16779}
16780
16781#[allow(clippy::too_many_arguments)]
16784fn handle_preview_dir_entry(
16785 root: &Path,
16786 path: &Path,
16787 name: &str,
16788 modified: String,
16789 depth: usize,
16790 parent_row_id: Option<usize>,
16791 row_id: usize,
16792 next_row_id: &mut usize,
16793 budget: &mut PreviewBudget,
16794 stats: &mut PreviewStats,
16795 rows: &mut Vec<PreviewRow>,
16796 languages: &mut Vec<&'static str>,
16797 include_patterns: &[String],
16798 exclude_patterns: &[String],
16799) -> Result<()> {
16800 let relative = preview_relative_path(root, path);
16801 if should_skip_preview_directory(&relative, exclude_patterns) {
16802 return Ok(());
16803 }
16804 stats.directories += 1;
16805 rows.push(PreviewRow {
16806 row_id,
16807 parent_row_id,
16808 depth: depth + 1,
16809 name: format!("{name}/"),
16810 kind: PreviewKind::Dir,
16811 is_dir: true,
16812 language: None,
16813 modified,
16814 type_label: "Directory".to_string(),
16815 });
16816 budget.shown += 1;
16817 if !matches!(name, ".git" | "node_modules" | "target") {
16818 collect_preview_rows(
16819 root,
16820 path,
16821 depth + 1,
16822 Some(row_id),
16823 next_row_id,
16824 budget,
16825 stats,
16826 rows,
16827 languages,
16828 include_patterns,
16829 exclude_patterns,
16830 )?;
16831 }
16832 Ok(())
16833}
16834
16835#[allow(clippy::too_many_arguments)]
16837fn handle_preview_file_entry(
16838 root: &Path,
16839 path: &Path,
16840 name: &str,
16841 modified: String,
16842 depth: usize,
16843 parent_row_id: Option<usize>,
16844 row_id: usize,
16845 budget: &mut PreviewBudget,
16846 stats: &mut PreviewStats,
16847 rows: &mut Vec<PreviewRow>,
16848 languages: &mut Vec<&'static str>,
16849 include_patterns: &[String],
16850 exclude_patterns: &[String],
16851) {
16852 let relative = preview_relative_path(root, path);
16853 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
16854 return;
16855 }
16856 stats.files += 1;
16857 let kind = classify_preview_file(name);
16858 match kind {
16859 PreviewKind::Supported => stats.supported += 1,
16860 PreviewKind::Skipped => stats.skipped += 1,
16861 PreviewKind::Unsupported => stats.unsupported += 1,
16862 PreviewKind::Dir => {}
16863 }
16864 let language = detect_language_name(name);
16865 if let Some(lang) = language {
16866 if !languages.contains(&lang) {
16867 languages.push(lang);
16868 }
16869 }
16870 rows.push(PreviewRow {
16871 row_id,
16872 parent_row_id,
16873 depth: depth + 1,
16874 name: name.to_owned(),
16875 kind,
16876 is_dir: false,
16877 language,
16878 modified,
16879 type_label: preview_type_label(name, language, kind),
16880 });
16881 budget.shown += 1;
16882}
16883
16884#[allow(clippy::too_many_arguments)]
16885#[allow(clippy::too_many_lines)]
16886fn collect_preview_rows(
16887 root: &Path,
16888 dir: &Path,
16889 depth: usize,
16890 parent_row_id: Option<usize>,
16891 next_row_id: &mut usize,
16892 budget: &mut PreviewBudget,
16893 stats: &mut PreviewStats,
16894 rows: &mut Vec<PreviewRow>,
16895 languages: &mut Vec<&'static str>,
16896 include_patterns: &[String],
16897 exclude_patterns: &[String],
16898) -> Result<()> {
16899 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
16900 return Ok(());
16901 }
16902
16903 let mut entries = fs::read_dir(dir)
16904 .with_context(|| format!("failed to read directory {}", dir.display()))?
16905 .filter_map(std::result::Result::ok)
16906 .collect::<Vec<_>>();
16907 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
16908
16909 for entry in entries {
16910 if budget.shown >= budget.max_entries {
16911 break;
16912 }
16913
16914 let path = entry.path();
16915 let name = entry.file_name().to_string_lossy().into_owned();
16916 let Ok(metadata) = entry.metadata() else {
16917 continue;
16918 };
16919 let row_id = *next_row_id;
16920 *next_row_id += 1;
16921 let modified = metadata
16922 .modified()
16923 .ok()
16924 .map_or_else(|| "-".to_string(), format_system_time);
16925
16926 if metadata.is_dir() {
16927 handle_preview_dir_entry(
16928 root,
16929 &path,
16930 &name,
16931 modified,
16932 depth,
16933 parent_row_id,
16934 row_id,
16935 next_row_id,
16936 budget,
16937 stats,
16938 rows,
16939 languages,
16940 include_patterns,
16941 exclude_patterns,
16942 )?;
16943 continue;
16944 }
16945
16946 if metadata.is_file() {
16947 handle_preview_file_entry(
16948 root,
16949 &path,
16950 &name,
16951 modified,
16952 depth,
16953 parent_row_id,
16954 row_id,
16955 budget,
16956 stats,
16957 rows,
16958 languages,
16959 include_patterns,
16960 exclude_patterns,
16961 );
16962 }
16963 }
16964
16965 Ok(())
16966}
16967
16968fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
16969 if let Some(language) = language {
16970 return format!("{language} source");
16971 }
16972 let lower = name.to_ascii_lowercase();
16973 let ext = Path::new(&lower)
16974 .extension()
16975 .and_then(|e| e.to_str())
16976 .unwrap_or("");
16977 match kind {
16978 PreviewKind::Skipped => {
16979 if lower.ends_with(".min.js") {
16980 "Minified asset".to_string()
16981 } else if [
16982 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
16983 ]
16984 .contains(&ext)
16985 {
16986 "Binary or archive".to_string()
16987 } else {
16988 "Skipped file".to_string()
16989 }
16990 }
16991 PreviewKind::Unsupported => {
16992 if ext.is_empty() {
16993 "Unsupported file".to_string()
16994 } else {
16995 format!("{} file", ext.to_ascii_uppercase())
16996 }
16997 }
16998 PreviewKind::Supported => "Supported source".to_string(),
16999 PreviewKind::Dir => "Directory".to_string(),
17000 }
17001}
17002
17003fn format_system_time(time: SystemTime) -> String {
17004 #[allow(clippy::cast_possible_wrap)]
17005 let secs = match time.duration_since(UNIX_EPOCH) {
17006 Ok(duration) => duration.as_secs() as i64,
17007 Err(_) => return "-".to_string(),
17008 };
17009 let days = secs.div_euclid(86_400);
17010 let secs_of_day = secs.rem_euclid(86_400);
17011 let (year, month, day) = civil_from_days(days);
17012 let hour = secs_of_day / 3_600;
17013 let minute = (secs_of_day % 3_600) / 60;
17014 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17015}
17016
17017#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17018fn civil_from_days(days: i64) -> (i32, u32, u32) {
17019 let z = days + 719_468;
17020 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17021 let doe = z - era * 146_097;
17022 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17023 let y = yoe + era * 400;
17024 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17025 let mp = (5 * doy + 2) / 153;
17026 let d = doy - (153 * mp + 2) / 5 + 1;
17027 let m = mp + if mp < 10 { 3 } else { -9 };
17028 let year = y + i64::from(m <= 2);
17029 (year as i32, m as u32, d as u32)
17030}
17031
17032#[allow(clippy::case_sensitive_file_extension_comparisons)]
17035fn detect_language_name(name: &str) -> Option<&'static str> {
17036 let lower = name.to_ascii_lowercase();
17037 if lower.ends_with(".c") || lower.ends_with(".h") {
17038 Some("C")
17039 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17040 .iter()
17041 .any(|s| lower.ends_with(s))
17042 {
17043 Some("C++")
17044 } else if lower.ends_with(".cs") {
17045 Some("C#")
17046 } else if lower.ends_with(".py") {
17047 Some("Python")
17048 } else if lower.ends_with(".sh") {
17049 Some("Shell")
17050 } else if [".ps1", ".psm1", ".psd1"]
17051 .iter()
17052 .any(|s| lower.ends_with(s))
17053 {
17054 Some("PowerShell")
17055 } else {
17056 None
17057 }
17058}
17059
17060fn language_icon_file(language: &str) -> Option<&'static str> {
17061 match language {
17062 "C" => Some("c.png"),
17063 "C++" => Some("cpp.png"),
17064 "C#" => Some("c-sharp.png"),
17065 "Python" => Some("python.png"),
17066 "Shell" => Some("shell.png"),
17067 "PowerShell" => Some("powershell.png"),
17068 "JavaScript" => Some("java-script.png"),
17069 "HTML" => Some("html-5.png"),
17070 "Java" => Some("java.png"),
17071 "Visual Basic" => Some("visual-basic.png"),
17072 "Assembly" => Some("asm.png"),
17073 "Go" => Some("go.png"),
17074 "R" => Some("r.png"),
17075 "XML" => Some("xml.png"),
17076 "Groovy" => Some("groovy.png"),
17077 "Dockerfile" => Some("docker.png"),
17078 "Makefile" => Some("makefile.svg"),
17079 "Perl" => Some("perl.svg"),
17080 _ => None,
17081 }
17082}
17083
17084fn language_inline_svg(language: &str) -> Option<&'static str> {
17089 match language {
17090 "Rust" => Some(
17091 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>"##,
17092 ),
17093 "TypeScript" => Some(
17094 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>"##,
17095 ),
17096 _ => None,
17097 }
17098}
17099
17100#[allow(clippy::case_sensitive_file_extension_comparisons)]
17103fn classify_preview_file(name: &str) -> PreviewKind {
17104 let lower = name.to_ascii_lowercase();
17105
17106 let scannable = [
17107 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17108 ".psm1", ".psd1",
17109 ]
17110 .iter()
17111 .any(|suffix| lower.ends_with(suffix));
17112
17113 if scannable {
17114 PreviewKind::Supported
17115 } else if lower.ends_with(".min.js")
17116 || lower.ends_with(".lock")
17117 || lower.ends_with(".png")
17118 || lower.ends_with(".jpg")
17119 || lower.ends_with(".jpeg")
17120 || lower.ends_with(".gif")
17121 || lower.ends_with(".zip")
17122 || lower.ends_with(".pdf")
17123 || lower.ends_with(".pyc")
17124 || lower.ends_with(".xz")
17125 || lower.ends_with(".tar")
17126 || lower.ends_with(".gz")
17127 {
17128 PreviewKind::Skipped
17129 } else {
17130 PreviewKind::Unsupported
17131 }
17132}
17133
17134fn preview_relative_path(root: &Path, path: &Path) -> String {
17135 path.strip_prefix(root)
17136 .ok()
17137 .unwrap_or(path)
17138 .to_string_lossy()
17139 .replace('\\', "/")
17140 .trim_matches('/')
17141 .to_string()
17142}
17143
17144fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17145 if relative.is_empty() {
17146 return false;
17147 }
17148
17149 exclude_patterns.iter().any(|pattern| {
17150 wildcard_match(pattern, relative)
17151 || wildcard_match(pattern, &format!("{relative}/"))
17152 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17153 })
17154}
17155
17156fn should_include_preview_file(
17157 relative: &str,
17158 include_patterns: &[String],
17159 exclude_patterns: &[String],
17160) -> bool {
17161 if relative.is_empty() {
17162 return true;
17163 }
17164
17165 let included = include_patterns.is_empty()
17166 || include_patterns
17167 .iter()
17168 .any(|pattern| wildcard_match(pattern, relative));
17169 let excluded = exclude_patterns
17170 .iter()
17171 .any(|pattern| wildcard_match(pattern, relative));
17172
17173 included && !excluded
17174}
17175
17176fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17177 let pattern = pattern.trim().replace('\\', "/");
17178 let candidate = candidate.trim().replace('\\', "/");
17179 let p = pattern.as_bytes();
17180 let c = candidate.as_bytes();
17181 let mut pi = 0usize;
17182 let mut ci = 0usize;
17183 let mut star: Option<usize> = None;
17184 let mut star_match = 0usize;
17185
17186 while ci < c.len() {
17187 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17188 pi += 1;
17189 ci += 1;
17190 } else if pi < p.len() && p[pi] == b'*' {
17191 while pi < p.len() && p[pi] == b'*' {
17192 pi += 1;
17193 }
17194 star = Some(pi);
17195 star_match = ci;
17196 } else if let Some(star_pi) = star {
17197 star_match += 1;
17198 ci = star_match;
17199 pi = star_pi;
17200 } else {
17201 return false;
17202 }
17203 }
17204
17205 while pi < p.len() && p[pi] == b'*' {
17206 pi += 1;
17207 }
17208
17209 pi == p.len()
17210}
17211
17212fn escape_html(value: &str) -> String {
17213 value
17214 .replace('&', "&")
17215 .replace('<', "<")
17216 .replace('>', ">")
17217 .replace('"', """)
17218 .replace('\'', "'")
17219}
17220
17221#[derive(Clone)]
17222struct SubmoduleRow {
17223 name: String,
17224 relative_path: String,
17225 files_analyzed: u64,
17226 code_lines: u64,
17227 comment_lines: u64,
17228 blank_lines: u64,
17229 total_physical_lines: u64,
17230 html_url: Option<String>,
17231}
17232
17233#[derive(Template)]
17234#[template(
17235 source = r##"
17236<!doctype html>
17237<html lang="en">
17238<head>
17239 <meta charset="utf-8">
17240 <title>OxideSLOC | tmp-sloc</title>
17241 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17242 <style nonce="{{ csp_nonce }}">
17243 :root {
17244 --bg: #efe9e2;
17245 --surface: #fcfaf7;
17246 --surface-2: #f7f0e8;
17247 --surface-3: #efe3d5;
17248 --line: #dfcfbf;
17249 --line-strong: #cfb29c;
17250 --text: #2f241c;
17251 --muted: #6f6257;
17252 --muted-2: #917f71;
17253 --nav: #b85d33;
17254 --nav-2: #7a371b;
17255 --accent: #2563eb;
17256 --accent-2: #1d4ed8;
17257 --oxide: #b85d33;
17258 --oxide-2: #8f4220;
17259 --success-bg: #eaf9ee;
17260 --success-text: #1c8746;
17261 --warn-bg: #fff2d8;
17262 --warn-text: #926000;
17263 --danger-bg: #fdeaea;
17264 --danger-text: #b33b3b;
17265 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17266 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17267 --radius: 14px;
17268 }
17269
17270 body.dark-theme {
17271 --bg: #1b1511;
17272 --surface: #261c17;
17273 --surface-2: #2d221d;
17274 --surface-3: #372922;
17275 --line: #524238;
17276 --line-strong: #6c5649;
17277 --text: #f5ece6;
17278 --muted: #c7b7aa;
17279 --muted-2: #aa9485;
17280 --nav: #b85d33;
17281 --nav-2: #7a371b;
17282 --accent: #6f9bff;
17283 --accent-2: #4a78ee;
17284 --oxide: #d37a4c;
17285 --oxide-2: #b35428;
17286 --success-bg: #163927;
17287 --success-text: #8fe2a8;
17288 --warn-bg: #3c2d11;
17289 --warn-text: #f3cb75;
17290 --danger-bg: #3d1f1f;
17291 --danger-text: #ff9f9f;
17292 --shadow: 0 14px 28px rgba(0,0,0,0.28);
17293 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17294 }
17295
17296 * { box-sizing: border-box; }
17297 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); }
17298 html { overflow-y: scroll; }
17299 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17300 .top-nav, .page, .loading { position: relative; z-index: 2; }
17301 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17302 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17303 .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); }
17304 .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; }
17305 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17306 .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)); }
17307 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17308 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17309 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17310 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17311 .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; }
17312 .nav-project-pill.visible { display:inline-flex; }
17313 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17314 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17315 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17316 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17317 @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; } }
17318 .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; }
17319 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17320 .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; }
17321 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17322 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17323 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17324 .theme-toggle .icon-sun { display:none; }
17325 body.dark-theme .theme-toggle .icon-sun { display:block; }
17326 body.dark-theme .theme-toggle .icon-moon { display:none; }
17327 .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;}
17328 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17329 .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);}
17330 .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;}
17331 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17332 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17333 .settings-modal-body{padding:14px 16px 16px;}
17334 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17335 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17336 .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;}
17337 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17338 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17339 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17340 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17341 .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;}
17342 .tz-select:focus{border-color:var(--oxide);}
17343 .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; }
17344 .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;}
17345 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17346 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17347 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17348 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17349 .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; }
17350 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17351 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17352 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17353 .wb-stats-header { padding: 10px 24px 0; }
17354 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17355 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17356 .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; }
17357 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17358 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17359 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17360 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17361 .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; }
17362 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17363 .ws-stat-analyzers { position: relative; }
17364 .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; }
17365 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17366 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17367 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17368 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17369 .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; }
17370 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17371 .ws-divider { display: none; }
17372 .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%; }
17373 .ws-path-link:hover { color:var(--oxide); }
17374 body.dark-theme .ws-path-link { color:var(--oxide); }
17375 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17376 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17377 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17378 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17379 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17380 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17381 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17382 .ws-mini-box-lg { flex:2 1 0; }
17383 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17384 .ws-mini-box-br { flex:1.5 1 0; }
17385 .scope-legend-row { display:flex; flex-direction:row; align-items:center; justify-content:flex-start; flex-wrap:nowrap; gap:0; padding:5px 10px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:12px; width:100%; min-width:0; border-left:3px solid var(--line-strong); white-space:nowrap; }
17386 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17387 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17388 #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; }
17389 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17390 .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; }
17391 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17392 .git-source-banner strong { font-weight:800; color:var(--text); }
17393 .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; }
17394 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17395 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17396 .git-source-banner a:hover { text-decoration:underline; }
17397 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17398 .path-scope-sep { background:var(--line); margin:4px 14px; }
17399 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17400 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17401 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17402 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17403 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17404 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17405 .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; }
17406 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17407 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17408 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17409 .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; }
17410 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17411 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17412 [data-wb-tip] { cursor:help; }
17413 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17414 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17415 .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; }
17416 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17417 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17418 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17419 .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; }
17420 .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); }
17421 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17422 .side-info-card { padding: 18px; }
17423 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17424 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17425 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17426 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17427 .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); }
17428 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17429 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17430 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17431 .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; }
17432 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17433 .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; }
17434 .side-stack::-webkit-scrollbar { display: none; }
17435 .step-nav { padding: 20px 16px; }
17436 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17437 .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; }
17438 .step-button:hover { background: var(--surface-2); }
17439 .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); }
17440 .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; }
17441 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17442 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17443 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17444 .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); }
17445 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17446 .step-nav-sum-row:last-child { border-bottom:none; }
17447 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17448 .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; }
17449 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17450 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17451 .quick-scan-section { padding: 10px 4px 14px; }
17452 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17453 .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; }
17454 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17455 .quick-scan-btn:active { transform:translateY(0); }
17456 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17457 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17458 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17459 @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);} }
17460 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17461 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17462 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17463 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17464 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17465 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17466 .step-button.done .step-check { opacity:1; }
17467 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17468 .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; }
17469 .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; }
17470 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17471 .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; }
17472 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17473 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17474 .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; }
17475 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17476 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17477 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17478 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17479 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17480 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17481 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17482 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17483 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17484 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17485 .card-body { padding: 22px; }
17486 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17487 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17488 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17489 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17490 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17491 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17492 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17493 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17494 .field { min-width:0; }
17495 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17496 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; }
17497 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); }
17498 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17499 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); }
17500 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17501 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17502 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17503 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17504 .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; }
17505 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17506 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17507 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17508 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17509 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17510 .input-group.compact { grid-template-columns: 1fr auto auto; }
17511 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17512 .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)); }
17513 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17514 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17515 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17516 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17517 .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; }
17518 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17519 .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; }
17520 .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); }
17521 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17522 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17523 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17524 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17525 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17526 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17527 @media (max-height: 1200px) { .workbench-strip { margin-bottom: 12px; } .wb-stats-header { padding: 8px 20px 0; } .ws-left { padding: 10px 16px 12px; } .ws-history-group { padding: 12px 20px; } }
17528 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17529 button.secondary { background: var(--surface); }
17530 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17531 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17532 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17533 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17534 .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); }
17535 .section + .wizard-actions { border-top: none; padding-top: 0; }
17536 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17537 .default-path-overlay { position: fixed; inset: 0; z-index: 9000; background: rgba(0,0,0,0.52); display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; pointer-events: none; transition: opacity .18s ease; }
17538 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17539 .default-path-modal { background: var(--surface); border: 1px solid var(--line); border-radius: 20px; max-width: 682px; width: 100%; box-shadow: 0 30px 80px rgba(0,0,0,0.34); padding: 33px 37px 29px; transform: translateY(10px); transition: transform .18s ease; }
17540 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17541 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17542 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17543 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17544 .default-path-modal p code { background: rgba(0,0,0,0.06); padding: 1px 6px; border-radius: 5px; font-size: 11.5px; color: var(--text); }
17545 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17546 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17547 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17548 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17549 .field-help-grid.coupled-help { margin-top: 12px; }
17550 .field-help-grid.preset-grid { align-items: start; }
17551 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17552 .preset-inline-row .field { margin: 0; }
17553 .preset-inline-row .explainer-card { margin: 0; }
17554 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17555 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17556 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17557 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17558 .preset-kv-row > :last-child { flex:1; min-width:0; }
17559 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17560 .output-field-row .field { margin: 0; }
17561 .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; }
17562 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17563 .step3-subtitle { margin-bottom: 10px; max-width: none; }
17564 .counting-intro { margin-bottom: 8px; max-width: none; }
17565 .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; }
17566 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17567 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17568 .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; }
17569 .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; }
17570 .section-spacer-top { margin-top: 28px; }
17571 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17572 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17573 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17574 .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); }
17575 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17576 .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; }
17577 .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; }
17578 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17579 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17580 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17581 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17582 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17583 .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; }
17584 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17585 .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); }
17586 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; }
17587 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; }
17588 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17589 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17590 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17591 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17592 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17593 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17594 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17595 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17596 .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); }
17597 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17598 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17599 .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; }
17600 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17601 .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; }
17602 .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; }
17603 .always-tracked-tip-body { flex:1; min-width:0; }
17604 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17605 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17606 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17607 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17608 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17609 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17610 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17611 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17612 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17613 .advanced-rule-description strong { color: var(--text); }
17614 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17615 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17616 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17617 .review-link:hover { text-decoration: underline; }
17618 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17619 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17620 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17621 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17622 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17623 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17624 .review-card ul { padding-left: 18px; margin: 0; }
17625 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17626 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17627 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17628 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17629 .review-card { min-height: 0; }
17630 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17631 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17632 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17633 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17634 .lang-overflow-chip { position:relative; cursor:default; }
17635 .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; }
17636 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17637 .git-inline-row { align-items:start; }
17638 .mixed-line-card { display:flex; flex-direction:column; }
17639 .preset-inline-row .toggle-card { justify-content: center; }
17640 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17641 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17642 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17643 .explorer-title { font-size: 18px; font-weight: 850; }
17644 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17645 .explorer-subtitle.wide { max-width: none; }
17646 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17647 .better-spacing { align-items:flex-start; justify-content:flex-end; }
17648 .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; }
17649 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17650 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17651 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17652 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17653 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
17654 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
17655 .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; }
17656 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
17657 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
17658 .scope-stat-button.supported { background: var(--success-bg); }
17659 .scope-stat-button.skipped { background: var(--warn-bg); }
17660 .scope-stat-button.unsupported { background: var(--danger-bg); }
17661 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
17662 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
17663 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
17664 [data-tooltip] { position: relative; }
17665 [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); }
17666 [data-tooltip]:hover::after { display: block; }
17667 .scope-stat-button[data-tooltip] { cursor: pointer; }
17668 .badge[data-tooltip] { cursor: help; }
17669 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
17670 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
17671 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
17672 .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; }
17673 .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; }
17674 code { display:inline-block; margin-top:0; padding:2px 7px; }
17675 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17676 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
17677 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
17678 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
17679 .language-pill.muted-pill { color: var(--muted); }
17680 button.language-pill { appearance:none; cursor:pointer; }
17681 .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); }
17682 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
17683 .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; }
17684 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
17685 .file-explorer-search-row { margin-left: auto; }
17686 .explorer-filter-select { min-width: 170px; width: 170px; }
17687 .explorer-search { min-width: 300px; width: 300px; }
17688 .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); }
17689 .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; }
17690 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
17691 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
17692 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
17693 .file-explorer-tree { max-height: 640px; overflow:auto; }
17694 .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); }
17695 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
17696 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
17697 .tree-row.hidden-by-filter { display:none !important; }
17698 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
17699 .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; }
17700 .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; }
17701 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
17702 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
17703 .tree-node { display:inline-flex; align-items:center; min-width:0; }
17704 .tree-node-dir { color: var(--text); font-weight: 800; }
17705 .tree-node-supported { color: var(--success-text); }
17706 .tree-node-skipped { color: var(--warn-text); }
17707 .tree-node-unsupported { color: var(--danger-text); }
17708 .tree-node-more { color: var(--muted-2); font-style: italic; }
17709 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
17710 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
17711 .tree-status-cell { display:flex; justify-content:flex-start; }
17712 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
17713 .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; }
17714 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17715 .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; }
17716 @keyframes prevSpin { to { transform:rotate(360deg); } }
17717 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
17718 .preview-gate-spinner { width:15px; height:15px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 15px; }
17719 .preview-gate-info { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; padding:0; border:none; background:transparent; color:var(--oxide); cursor:pointer; border-radius:50%; flex:0 0 18px; transition:transform .15s ease, color .15s ease; }
17720 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
17721 .preview-gate-info svg { width:16px; height:16px; }
17722 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
17723 @keyframes previewPanelFlash { 0%,100% { box-shadow:0 0 0 0 rgba(196,93,42,0); } 25% { box-shadow:0 0 0 4px rgba(196,93,42,0.45); } }
17724 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
17725 .preview-loading-text { flex:1; min-width:0; }
17726 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
17727 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
17728 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
17729 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
17730 .cov-scan-idle { display:none; }
17731 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
17732 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
17733 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
17734 .cov-scan-title { font-weight:600; font-size:12.5px; }
17735 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
17736 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
17737 .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; }
17738 .cov-scan-use:hover { opacity:.75; }
17739 .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; }
17740 .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; }
17741 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
17742 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
17743 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
17744 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
17745 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
17746 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
17747 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
17748 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
17749 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
17750 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
17751 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
17752 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
17753 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
17754 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
17755 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
17756 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
17757 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
17758 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
17759 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
17760 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
17761 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
17762 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
17763 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
17764 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
17765 .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); }
17766 .loading.active { display:flex; }
17767 /* Lock page scroll while the analysis modal is open so the removed scrollbar
17768 gutter doesn't pull the centered card slightly left of true center. */
17769 body.modal-open { overflow: hidden; }
17770 .loading-card { position:relative; overflow:hidden; width: min(840px, calc(100vw - 40px)); border-radius: 20px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 24px 56px rgba(0,0,0,0.26); padding: 42px 48px; }
17771 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
17772 .loading-card::before { content:''; position:absolute; inset:0; z-index:0; pointer-events:none; border-radius:inherit; opacity:0; background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.22), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(37,99,235,0.16), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.14), transparent 60%); transition: opacity .4s ease; }
17773 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
17774 .loading-card > * { position:relative; z-index:1; }
17775 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
17776 body.dark-theme .loading-card::before { background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.26), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(111,155,255,0.18), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.18), transparent 60%); }
17777 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
17778 .progress-bar span { display:block; width:35%; height:100%; border-radius:999px; background: linear-gradient(90deg, transparent, var(--accent-2) 22%, var(--oxide,#d37a4c) 78%, transparent); will-change: transform; animation: pulseBar 1.5s linear infinite; }
17779 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
17780 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
17781 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
17782 .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; }
17783 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
17784 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
17785 .lc-metric-label { font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17786 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17787 .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; }
17788 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
17789 .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; }
17790 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
17791 .lc-step.done { color:var(--muted);opacity:0.55; }
17792 .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; }
17793 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
17794 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
17795 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
17796 .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; }
17797 .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; }
17798 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
17799 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
17800 .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; }
17801 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
17802 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
17803 .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; }
17804 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
17805 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
17806 .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; }
17807 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
17808 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
17809 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
17810 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
17811 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
17812 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
17813 .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; }
17814 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
17815 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
17816 .hidden { display:none !important; }
17817 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17818 .site-footer a{color:var(--muted);}
17819 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
17820 @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; } }
17821 .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;}
17822 @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));}}
17823 .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;}
17824 .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; }
17825 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
17826 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
17827 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
17828 .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; }
17829 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
17830 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
17831 .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(7px); background:var(--text); color:var(--bg); padding:5px 10px; border-radius:7px; font-size:11px; font-weight:600; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; }
17832 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
17833 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
17834 .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; }
17835 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
17836 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
17837 .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; }
17838 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
17839 .info-icon-btn:hover { color:var(--text); }
17840 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); }
17841 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
17842 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
17843 .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;}
17844 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
17845 .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;}
17846 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
17847 #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);}
17848 #offline-file-banner.show{display:flex;}
17849 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
17850 #offline-file-banner .ofb-text{flex:1;}
17851 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
17852 #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;}
17853 #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;}
17854 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
17855 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
17856 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
17857 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
17858 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
17859 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
17860 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
17861 </style>
17862</head>
17863<body id="page-top">
17864 <div id="offline-file-banner" role="alert">
17865 <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>
17866 <span class="ofb-text">
17867 Charts, images, and navigation require the oxide-sloc server.
17868 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
17869 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
17870 The metric tables below are fully readable without the server.
17871 </span>
17872 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
17873 </div>
17874 <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>
17875 <div class="background-watermarks" aria-hidden="true">
17876 <img src="/images/logo/logo-text.png" alt="" />
17877 <img src="/images/logo/logo-text.png" alt="" />
17878 <img src="/images/logo/logo-text.png" alt="" />
17879 <img src="/images/logo/logo-text.png" alt="" />
17880 <img src="/images/logo/logo-text.png" alt="" />
17881 <img src="/images/logo/logo-text.png" alt="" />
17882 <img src="/images/logo/logo-text.png" alt="" />
17883 <img src="/images/logo/logo-text.png" alt="" />
17884 <img src="/images/logo/logo-text.png" alt="" />
17885 <img src="/images/logo/logo-text.png" alt="" />
17886 <img src="/images/logo/logo-text.png" alt="" />
17887 <img src="/images/logo/logo-text.png" alt="" />
17888 <img src="/images/logo/logo-text.png" alt="" />
17889 <img src="/images/logo/logo-text.png" alt="" />
17890 </div>
17891 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17892 <div class="top-nav">
17893 <div class="top-nav-inner">
17894 <a class="brand" href="/">
17895 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17896 <div class="brand-copy">
17897 <div class="brand-title">OxideSLOC</div>
17898 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17899 </div>
17900 </a>
17901 <div class="nav-project-slot">
17902 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
17903 <span class="nav-project-label">Project</span>
17904 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
17905 </div>
17906 </div>
17907 <div class="nav-status">
17908 <a class="nav-pill" href="/">Home</a>
17909 <div class="nav-dropdown">
17910 <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>
17911 <div class="nav-dropdown-menu">
17912 <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>
17913 </div>
17914 </div>
17915 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17916 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17917 <div class="nav-dropdown">
17918 <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>
17919 <div class="nav-dropdown-menu">
17920 <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>
17921 </div>
17922 </div>
17923 <div class="server-status-wrap" id="server-status-wrap">
17924 <div class="nav-pill server-online-pill" id="server-status-pill">
17925 <span class="status-dot" id="status-dot"></span>
17926 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
17927 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17928 </div>
17929 <div class="server-status-tip">
17930 {% if server_mode %}
17931 OxideSLOC is running in server mode — accessible on your LAN.
17932 {% else %}
17933 OxideSLOC is running locally — only accessible from this machine.
17934 {% endif %}
17935 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17936 </div>
17937 </div>
17938 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17939 <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>
17940 </button>
17941 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17942 <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>
17943 <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>
17944 </button>
17945 </div>
17946 </div>
17947 </div>
17948
17949 <div class="loading" id="loading">
17950 <div class="loading-card" id="loading-card">
17951 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
17952 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17953 <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>
17954 <div class="lc-steps" id="lc-steps">
17955 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
17956 <div class="lc-step-arrow">›</div>
17957 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
17958 <div class="lc-step-arrow">›</div>
17959 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
17960 <div class="lc-step-arrow">›</div>
17961 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
17962 </div>
17963 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
17964 <div class="lc-metrics" id="lc-metrics">
17965 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
17966 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
17967 <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>
17968 <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>
17969 </div>
17970 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
17971 <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>
17972 <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>
17973 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
17974 <div class="lc-actions hidden" id="lc-actions">
17975 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
17976 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
17977 </div>
17978 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
17979 <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>
17980 Cancel scan
17981 </button>
17982 </div>
17983 </div>
17984
17985 <div class="page">
17986 <div class="workbench-strip">
17987 <div class="workbench-box wb-stats">
17988 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
17989 <span class="wb-stats-title">Analysis session</span>
17990 </div>
17991 <div class="ws-left">
17992 <div class="ws-stat ws-stat-analyzers">
17993 <span class="ws-label">Analyzers</span>
17994 <span class="ws-value">
17995 <span class="ws-badge">60 languages</span>
17996 </span>
17997 <div class="ws-lang-tooltip">
17998 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
17999 <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>
18000 <div class="ws-lang-grid">
18001 <span class="ws-lang-item">Assembly</span>
18002 <span class="ws-lang-item">C</span>
18003 <span class="ws-lang-item">C++</span>
18004 <span class="ws-lang-item">C#</span>
18005 <span class="ws-lang-item">Clojure</span>
18006 <span class="ws-lang-item">CSS</span>
18007 <span class="ws-lang-item">Dart</span>
18008 <span class="ws-lang-item">Dockerfile</span>
18009 <span class="ws-lang-item">Elixir</span>
18010 <span class="ws-lang-item">Erlang</span>
18011 <span class="ws-lang-item">F#</span>
18012 <span class="ws-lang-item">Go</span>
18013 <span class="ws-lang-item">Groovy</span>
18014 <span class="ws-lang-item">Haskell</span>
18015 <span class="ws-lang-item">HTML</span>
18016 <span class="ws-lang-item">Java</span>
18017 <span class="ws-lang-item">JavaScript</span>
18018 <span class="ws-lang-item">Julia</span>
18019 <span class="ws-lang-item">Kotlin</span>
18020 <span class="ws-lang-item">Lua</span>
18021 <span class="ws-lang-item">Makefile</span>
18022 <span class="ws-lang-item">Nim</span>
18023 <span class="ws-lang-item">Obj-C</span>
18024 <span class="ws-lang-item">OCaml</span>
18025 <span class="ws-lang-item">Perl</span>
18026 <span class="ws-lang-item">PHP</span>
18027 <span class="ws-lang-item">PowerShell</span>
18028 <span class="ws-lang-item">Python</span>
18029 <span class="ws-lang-item">R</span>
18030 <span class="ws-lang-item">Ruby</span>
18031 <span class="ws-lang-item">Rust</span>
18032 <span class="ws-lang-item">Scala</span>
18033 <span class="ws-lang-item">SCSS</span>
18034 <span class="ws-lang-item">Shell</span>
18035 <span class="ws-lang-item">SQL</span>
18036 <span class="ws-lang-item">Svelte</span>
18037 <span class="ws-lang-item">Swift</span>
18038 <span class="ws-lang-item">TypeScript</span>
18039 <span class="ws-lang-item">Vue</span>
18040 <span class="ws-lang-item">XML</span>
18041 <span class="ws-lang-item">Zig</span>
18042 <span class="ws-lang-item">Solidity</span>
18043 <span class="ws-lang-item">Protobuf</span>
18044 <span class="ws-lang-item">HCL</span>
18045 <span class="ws-lang-item">GraphQL</span>
18046 <span class="ws-lang-item">Ada</span>
18047 <span class="ws-lang-item">VHDL</span>
18048 <span class="ws-lang-item">Verilog</span>
18049 <span class="ws-lang-item">Tcl</span>
18050 <span class="ws-lang-item">Pascal</span>
18051 <span class="ws-lang-item">Visual Basic</span>
18052 <span class="ws-lang-item">Lisp</span>
18053 <span class="ws-lang-item">Fortran</span>
18054 <span class="ws-lang-item">Nix</span>
18055 <span class="ws-lang-item">Crystal</span>
18056 <span class="ws-lang-item">D</span>
18057 <span class="ws-lang-item">GLSL</span>
18058 <span class="ws-lang-item">CMake</span>
18059 <span class="ws-lang-item">Elm</span>
18060 <span class="ws-lang-item">Awk</span>
18061 </div>
18062 </div>
18063 </div>
18064 <div class="ws-divider"></div>
18065 <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>
18066 <div class="ws-divider"></div>
18067 <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts \u2014 JSON, HTML, and PDF reports \u2014 are written after each completed scan.">
18068 <span class="ws-label">Output</span>
18069 <span class="ws-value">
18070 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18071 <span id="ws-output-root">project/sloc</span>
18072 </button>
18073 </span>
18074 </div>
18075 </div>
18076 </div>
18077 <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.">
18078 <div class="ws-history-label">Scan history</div>
18079 <div class="ws-history-inner">
18080 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18081 <div class="ws-mini-label">Scans</div>
18082 <div class="ws-mini-value" id="ws-scan-count">—</div>
18083 </div>
18084 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18085 <div class="ws-mini-label">Last Scan</div>
18086 <div class="ws-mini-value" id="ws-last-scan">—</div>
18087 </div>
18088 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18089 <div class="ws-mini-label">Branch</div>
18090 <div class="ws-mini-value" id="ws-branch">—</div>
18091 </div>
18092 </div>
18093 </div>
18094 </div>
18095
18096 <div class="layout">
18097 <aside class="side-stack">
18098 <section class="step-nav">
18099 <h3>Guided scan setup</h3>
18100 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18101 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18102 Top of page
18103 </a>
18104 <button type="button" class="step-button active" style="margin-top:10px;" data-step-target="1"><span class="step-num">1</span><span>Select project</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18105 <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>
18106 <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>
18107 <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>
18108
18109 <div class="step-steps-divider"></div>
18110
18111 <div class="step-nav-info" id="step-nav-info">
18112 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18113 <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>
18114 </div>
18115
18116 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18117 <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>
18118 <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>
18119 <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>
18120 </div>
18121
18122 <div class="quick-scan-divider"></div>
18123 <div class="quick-scan-section">
18124 <div class="quick-scan-label">No customization needed?</div>
18125 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18126 <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>
18127 Quick Scan
18128 </button>
18129 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18130 </div>
18131
18132 <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>
18133 <div class="sidebar-scroll-divider"></div>
18134 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18135 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18136 Skip to bottom
18137 </a>
18138 </section>
18139
18140 </aside>
18141
18142 <section class="card">
18143 <div class="card-header">
18144 <div class="card-title-row">
18145 <div>
18146 <h1 class="card-title">Guided scan configuration</h1>
18147 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18148 </div>
18149 <div class="wizard-progress" aria-label="Scan setup progress">
18150 <div class="wizard-progress-top">
18151 <span class="wizard-progress-label">Setup progress</span>
18152 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18153 </div>
18154 <div class="wizard-progress-track">
18155 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18156 </div>
18157 </div>
18158 </div>
18159 </div>
18160 <div class="card-body">
18161 <form method="post" action="/analyze" id="analyze-form">
18162 <div class="wizard-step active" data-step="1">
18163 <div class="section">
18164 <div class="section-kicker">Step 1</div>
18165 <h2>Select project and preview scope</h2>
18166 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18167 <div class="field">
18168 <label for="path">Project path</label>
18169 {% if !git_repo.is_empty() %}
18170 <div class="git-source-banner">
18171 <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>
18172 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18173 <a href="/git-browser">← Back to Git Browser</a>
18174 </div>
18175 {% endif %}
18176 <div class="path-scope-grid">
18177 {% if !git_repo.is_empty() %}
18178 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18179 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18180 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18181 {% else %}
18182 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
18183 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18184 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18185 {% endif %}
18186 <div class="path-scope-sep"></div>
18187 <div class="scope-legend-row">
18188 <span class="scope-legend-label">Scope legend:</span>
18189 <span class="scope-legend-badges">
18190 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer \u2014 counted in SLOC totals.">supported</span>
18191 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18192 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set \u2014 listed but not counted.">unsupported</span>
18193 </span>
18194 </div>
18195 </div>
18196 {% if git_repo.is_empty() %}
18197 {% if server_mode %}
18198 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18199 ℹ️ Files are compressed and streamed — no fixed size limit.
18200 </div>
18201 {% endif %}
18202 <div class="path-info-row">
18203 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18204 <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>
18205 <span id="project-size-text">Project size: —</span>
18206 </button>
18207 </div>
18208 {% else %}
18209 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18210 {% endif %}
18211 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18212 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18213 </div>
18214
18215 <div class="scope-preview-divider" aria-hidden="true"></div>
18216
18217 <div id="preview-panel">
18218 <div class="preview-error">Loading preview...</div>
18219 </div>
18220 </div>
18221
18222 <div class="section" style="margin-top:14px;">
18223 <div class="preset-inline-row git-inline-row">
18224 <div class="toggle-card" style="margin:0;">
18225 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18226 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18227 <label class="checkbox">
18228 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18229 <div>
18230 <span>Detect and separate git submodules</span>
18231 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18232 </div>
18233 </label>
18234 </div>
18235 <div class="explainer-card prominent" style="margin:0;">
18236 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18237 <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>
18238 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18239 path = libs/core
18240 url = https://github.com/org/core.git
18241
18242[submodule "libs/ui"]
18243 path = libs/ui
18244 url = https://github.com/org/ui.git</div>
18245 </div>
18246 </div>
18247 </div>
18248
18249 <div class="section">
18250 <div class="field-grid">
18251 <div class="field">
18252 <div class="glob-label-row">
18253 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18254 <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>
18255 </div>
18256 <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>
18257 <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>
18258 </div>
18259 <div class="field">
18260 <div class="glob-label-row">
18261 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18262 </div>
18263 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
18264 <div id="quick-exclude-chips" class="quick-excl-row">
18265 <span class="quick-excl-label">Quick add:</span>
18266 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18267 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18268 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18269 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18270 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18271 <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>
18272 </div>
18273 <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>
18274 </div>
18275 </div>
18276 <div class="glob-guidance-grid">
18277 <div class="glob-guidance-card">
18278 <strong>How to read them</strong>
18279 <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>
18280 </div>
18281 <div class="glob-guidance-card">
18282 <strong>Common include examples</strong>
18283 <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>
18284 </div>
18285 <div class="glob-guidance-card">
18286 <strong>Common exclude examples</strong>
18287 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18288 </div>
18289 </div>
18290 </div>
18291
18292 <div class="section" style="margin-top:14px;">
18293 <div class="preset-inline-row git-inline-row">
18294 <div class="toggle-card" style="margin:0;">
18295 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18296 <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>
18297 <div class="field" style="margin:0;">
18298 <div class="input-group compact">
18299 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18300 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18301 </div>
18302 <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>
18303 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18304 </div>
18305 </div>
18306 <div class="explainer-card prominent" style="margin:0;">
18307 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18308 <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>
18309 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18310lcov --capture --directory . --output-file coverage/lcov.info
18311
18312# C / C++ — llvm-cov (LCOV)
18313llvm-profdata merge -sparse default.profraw -o default.profdata
18314llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18315
18316# C# — coverlet (Cobertura XML)
18317dotnet test --collect:"XPlat Code Coverage"
18318
18319# Python — pytest-cov (Cobertura XML)
18320pytest --cov --cov-report=xml
18321
18322# Python — coverage.py native JSON
18323coverage run -m pytest && coverage json # writes coverage.json
18324
18325# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18326./gradlew jacocoTestReport</div>
18327 </div>
18328 </div>
18329 </div>
18330
18331 <div class="wizard-actions">
18332 <div class="left"></div>
18333 <div class="right">
18334 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18335 <span class="preview-gate-spinner" aria-hidden="true"></span>
18336 <span class="preview-gate-text">Scanning project scope…</span>
18337 <button type="button" class="preview-gate-info" id="preview-gate-info" title="What is this? Jump up to the live scope preview" aria-label="Show what is being scanned — jump to the scope preview">
18338 <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>
18339 </button>
18340 </div>
18341 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18342 </div>
18343 </div>
18344 </div>
18345
18346 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18347 <div class="default-path-modal">
18348 <h3 id="default-path-title">
18349 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
18350 Proceed with the default sample test?
18351 </h3>
18352 <p>The <strong>Project path</strong> is still set to the bundled sample <code>tests/fixtures/basic</code></p>
18353 <p>You haven't selected your own project yet.</p>
18354 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18355 <div class="default-path-actions">
18356 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18357 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18358 </div>
18359 </div>
18360 </div>
18361
18362 <div class="wizard-step" data-step="2">
18363 <div class="section">
18364 <div class="section-kicker">Step 2</div>
18365 <h2>Choose counting behavior</h2>
18366 <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>
18367<div class="subsection-bar">Primary line classification</div>
18368 <div class="preset-kv-row">
18369 <div class="toggle-card mixed-line-card" style="margin:0;">
18370 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18371 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18372 <select id="mixed_line_policy" name="mixed_line_policy">
18373 <option value="code_only">Code only</option>
18374 <option value="code_and_comment">Code and comment</option>
18375 <option value="comment_only">Comment only</option>
18376 <option value="separate_mixed_category">Separate mixed category</option>
18377 </select>
18378 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18379 </div>
18380 <div class="explainer-card prominent" style="margin:0;">
18381 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18382 <div class="explainer-body" id="mixed-policy-description"></div>
18383 <div class="code-sample" id="mixed-policy-example"></div>
18384 </div>
18385 </div>
18386 </div>
18387
18388 <div class="subsection-bar">Additional scan rules</div>
18389 <div class="scan-rules-grid">
18390 <div class="preset-inline-row">
18391 <div class="toggle-card" style="margin:0;">
18392 <div class="field-help-title">Generated files</div>
18393 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18394 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18395 </div>
18396 <div class="explainer-card prominent" style="margin:0;">
18397 <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>
18398 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18399# Files matching codegen patterns are excluded:
18400# *.generated.cs *.pb.go *.g.dart</div>
18401 </div>
18402 </div>
18403 <div class="preset-inline-row">
18404 <div class="toggle-card" style="margin:0;">
18405 <div class="field-help-title">Minified files</div>
18406 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18407 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18408 </div>
18409 <div class="explainer-card prominent" style="margin:0;">
18410 <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>
18411 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18412# Heuristic: very long lines + low whitespace ratio
18413# jquery.min.js bundle.min.css → skipped</div>
18414 </div>
18415 </div>
18416 <div class="preset-inline-row">
18417 <div class="toggle-card" style="margin:0;">
18418 <div class="field-help-title">Vendor directories</div>
18419 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18420 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18421 </div>
18422 <div class="explainer-card prominent" style="margin:0;">
18423 <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>
18424 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18425# Directories named vendor/ node_modules/ third_party/
18426# → entire subtree is excluded from totals</div>
18427 </div>
18428 </div>
18429 <div class="preset-inline-row">
18430 <div class="toggle-card" style="margin:0;">
18431 <div class="field-help-title">Lockfiles and manifests</div>
18432 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18433 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18434 </div>
18435 <div class="explainer-card prominent" style="margin:0;">
18436 <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>
18437 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
18438# Files like package-lock.json Cargo.lock yarn.lock
18439# → skipped unless this is enabled</div>
18440 </div>
18441 </div>
18442 <div class="preset-inline-row">
18443 <div class="toggle-card" style="margin:0;">
18444 <div class="field-help-title">Binary handling</div>
18445 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18446 <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>
18447 </div>
18448 <div class="explainer-card prominent" style="margin:0;">
18449 <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>
18450 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
18451# Detected via long lines + low whitespace heuristic
18452# .png .exe .so → skipped silently</div>
18453 </div>
18454 </div>
18455 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18456 <div class="toggle-card" style="margin:0;">
18457 <div class="field-help-title">Python docstrings</div>
18458 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18459 <label class="checkbox">
18460 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18461 <span>Count as comment-style lines</span>
18462 </label>
18463 </div>
18464 <div class="explainer-card prominent" style="margin:0;">
18465 <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>
18466 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18467 </div>
18468 </div>
18469 </div>
18470 <div class="subsection-bar">IEEE 1045-1992 counting</div>
18471 <div class="scan-rules-grid">
18472 <div class="preset-inline-row">
18473 <div class="toggle-card" style="margin:0;">
18474 <div class="field-help-title">Continuation lines</div>
18475 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18476 <select name="continuation_line_policy" id="continuation_line_policy">
18477 <option value="each_physical_line" selected>Each physical line (default)</option>
18478 <option value="collapse_to_logical">Collapse to logical line</option>
18479 </select>
18480 </div>
18481 <div class="explainer-card prominent" style="margin:0;">
18482 <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>
18483 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18484 ((a) > (b) ? (a) : (b))
18485# each_physical_line → 2 SLOC
18486# collapse_to_logical → 1 SLOC</div>
18487 </div>
18488 </div>
18489 <div class="preset-inline-row">
18490 <div class="toggle-card" style="margin:0;">
18491 <div class="field-help-title">Block-comment blanks</div>
18492 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18493 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18494 <option value="count_as_comment" selected>Count as comment (default)</option>
18495 <option value="count_as_blank">Count as blank</option>
18496 </select>
18497 </div>
18498 <div class="explainer-card prominent" style="margin:0;">
18499 <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>
18500 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18501 * Summary line
18502 * ← blank inside block comment
18503 * Detail line
18504 */
18505# count_as_comment → blank counts toward comments
18506# count_as_blank → blank counts toward blanks</div>
18507 </div>
18508 </div>
18509 <div class="preset-inline-row">
18510 <div class="toggle-card" style="margin:0;">
18511 <div class="field-help-title">Compiler directives</div>
18512 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18513 <select name="count_compiler_directives" id="count_compiler_directives">
18514 <option value="enabled" selected>Include in code SLOC (default)</option>
18515 <option value="disabled">Exclude from code SLOC</option>
18516 </select>
18517 </div>
18518 <div class="explainer-card prominent" style="margin:0;">
18519 <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>
18520 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
18521#define BUF 256 ← compiler directive
18522int main() { … } ← code
18523# enabled → 3 code SLOC
18524# disabled → 1 code SLOC + 2 directive lines</div>
18525 </div>
18526 </div>
18527 </div>
18528
18529 <div class="subsection-bar">Code Style Analysis</div>
18530 <div class="scan-rules-grid">
18531 <div class="preset-inline-row">
18532 <div class="toggle-card" style="margin:0;">
18533 <div class="field-help-title">Style analysis</div>
18534 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18535 <select name="style_analysis_enabled" id="style_analysis_enabled">
18536 <option value="enabled" selected>Enabled (default)</option>
18537 <option value="disabled">Disabled — skip style scoring</option>
18538 </select>
18539 </div>
18540 <div class="explainer-card prominent" style="margin:0;">
18541 <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> \u2014 every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> \u2014 style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
18542 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
18543# style_analysis_enabled = false (skip, faster scan)
18544# Disabling removes the Code Style section from the report.</div>
18545 </div>
18546 </div>
18547 <div class="preset-inline-row">
18548 <div class="toggle-card" style="margin:0;">
18549 <div class="field-help-title">Column-width threshold</div>
18550 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18551 <select name="style_col_threshold" id="style_col_threshold">
18552 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18553 <option value="100">100 columns (Uber Go, Google Java)</option>
18554 <option value="120">120 columns (Uber Go max, Kotlin)</option>
18555 </select>
18556 </div>
18557 <div class="explainer-card prominent" style="margin:0;">
18558 <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>
18559 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
18560# style_col_threshold = 100 (Uber Go, Google Java)
18561# style_col_threshold = 120 (Uber Go max, Kotlin)
18562# Files where <= 5% of lines exceed the limit
18563# are counted as "N-col compliant" in the report.</div>
18564 </div>
18565 </div>
18566 <div class="preset-inline-row">
18567 <div class="toggle-card" style="margin:0;">
18568 <div class="field-help-title">Score alert threshold</div>
18569 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18570 <select name="style_score_threshold" id="style_score_threshold">
18571 <option value="0" selected>Off — no threshold (default)</option>
18572 <option value="40">40% — flag poorly styled files</option>
18573 <option value="50">50% — flag below-average files</option>
18574 <option value="60">60% — flag below-good files</option>
18575 <option value="70">70% — flag below-strong files</option>
18576 </select>
18577 </div>
18578 <div class="explainer-card prominent" style="margin:0;">
18579 <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>
18580 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
18581# style_score_threshold = 50 (flag files < 50%)
18582# Low-scoring files get a red left-border in the
18583# per-file style breakdown table.</div>
18584 </div>
18585 </div>
18586 </div>
18587
18588 <div class="always-tracked-tip">
18589 <div class="always-tracked-tip-icon">ℹ</div>
18590 <div class="always-tracked-tip-body">
18591 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
18592 <h4>Comment and blank-line basics & Lines on the boundary</h4>
18593 <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>
18594 </div>
18595 </div>
18596
18597 <div class="subsection-bar">Advanced Metrics</div>
18598 <div class="scan-rules-grid">
18599 <div class="preset-inline-row">
18600 <div class="toggle-card" style="margin:0;">
18601 <div class="field-help-title">COCOMO mode</div>
18602 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18603 <select name="cocomo_mode" id="cocomo_mode">
18604 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18605 <option value="semi_detached">Semi-detached — mixed constraints</option>
18606 <option value="embedded">Embedded — tight hardware/OS constraints</option>
18607 </select>
18608 </div>
18609 <div class="explainer-card prominent" style="margin:0;">
18610 <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>
18611 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
18612# Semi-detached: Effort = 3.0 × KSLOC^1.12
18613# Embedded: Effort = 3.6 × KSLOC^1.20
18614# All modes: Schedule = 2.5 × Effort^d</div>
18615 </div>
18616 </div>
18617 <div class="preset-inline-row">
18618 <div class="toggle-card" style="margin:0;">
18619 <div class="field-help-title">Complexity alert</div>
18620 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18621 <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;" />
18622 </div>
18623 <div class="explainer-card prominent" style="margin:0;">
18624 <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>
18625 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18626# 50 = flag any file with > 50 branch points
18627# 100 = flag any file with > 100 branch points
18628# Files above the threshold are highlighted
18629# in the result page metric strip.</div>
18630 </div>
18631 </div>
18632 <div class="preset-inline-row">
18633 <div class="toggle-card" style="margin:0;">
18634 <div class="field-help-title">Git hotspots</div>
18635 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18636 <input type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
18637 </div>
18638 <div class="explainer-card prominent" style="margin:0;">
18639 <div class="advanced-rule-description"><strong>Purpose:</strong> <strong>On by default (90 days).</strong> oxide-sloc runs a single <code>git log</code> pass over the last N days and ranks files by <strong>code lines × recent commits</strong> in a Git Hotspots table — large files that change often are the strongest refactoring candidates.<br /><strong>Requires</strong> the scanned path to be a git repository. This is distinct from the scan-to-scan churn rate shown on the Compare page.</div>
18640 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
18641# 30 = last month of activity
18642# 365 = last year
18643# 0 = disable the hotspots table
18644# Adds Commits + Last-changed columns to CSV.</div>
18645 </div>
18646 </div>
18647 <div class="preset-inline-row">
18648 <div class="toggle-card" style="margin:0;">
18649 <div class="field-help-title">Duplicate handling</div>
18650 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
18651 <select name="exclude_duplicates" id="exclude_duplicates">
18652 <option value="disabled" selected>Detect and report only (default)</option>
18653 <option value="enabled">Detect and exclude from SLOC totals</option>
18654 </select>
18655 </div>
18656 <div class="explainer-card prominent" style="margin:0;">
18657 <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>
18658 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
18659# detect only → all 3 counted in SLOC
18660# exclude dupes → 1 counted, 2 excluded
18661# Duplicate groups chip always shows the count.</div>
18662 </div>
18663 </div>
18664 <div class="always-tracked-tip" style="margin:8px 0 0;">
18665 <div class="always-tracked-tip-icon">ℹ</div>
18666 <div class="always-tracked-tip-body">
18667 <div class="field-help-title">Always computed — every scan produces these automatically</div>
18668 <div class="always-tracked-metrics-row">
18669 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
18670 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
18671 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
18672 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
18673 </div>
18674 <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>
18675 </div>
18676 </div>
18677 </div>
18678
18679 <div class="wizard-actions">
18680 <div class="left">
18681 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
18682 </div>
18683 <div class="right">
18684 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
18685 </div>
18686 </div>
18687 </div>
18688
18689 <div class="wizard-step" data-step="3">
18690 <div class="section">
18691 <div class="section-kicker">Step 3</div>
18692 <h2>Output and report identity</h2>
18693 <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>
18694 <div class="preset-kv-row">
18695 <div class="toggle-card" style="margin:0;">
18696 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
18697 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
18698 <select id="scan_preset">
18699 <option value="balanced">Balanced local scan</option>
18700 <option value="code_focused">Code focused</option>
18701 <option value="comment_audit">Comment audit</option>
18702 <option value="deep_review">Deep review</option>
18703 </select>
18704 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
18705 </div>
18706 <div class="explainer-card">
18707 <div class="field-help-title">Selected scan preset</div>
18708 <div class="explainer-body" id="scan-preset-description"></div>
18709 <div class="preset-summary-row" id="scan-preset-summary"></div>
18710 <div class="code-sample" id="scan-preset-example"></div>
18711 <div class="preset-note" id="scan-preset-note"></div>
18712 </div>
18713 </div>
18714 <hr class="step3-separator" />
18715 <div class="preset-kv-row">
18716 <div class="toggle-card" style="margin:0;">
18717 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
18718 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
18719 <select id="artifact_preset">
18720 <option value="review">Review bundle</option>
18721 <option value="full">Full bundle</option>
18722 <option value="html_only">HTML only</option>
18723 <option value="machine">Machine bundle</option>
18724 </select>
18725 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
18726 </div>
18727 <div class="explainer-card">
18728 <div class="field-help-title">Selected artifact preset</div>
18729 <div class="explainer-body" id="artifact-preset-description"></div>
18730 <div class="preset-summary-row" id="artifact-preset-summary"></div>
18731 <div class="code-sample" id="artifact-preset-example"></div>
18732 </div>
18733 </div>
18734 </div>
18735
18736 <div class="section section-spacer-top">
18737 <div class="output-field-row">
18738 <div class="field">
18739 <label for="output_dir">Output directory</label>
18740 {% if server_mode %}
18741 <div class="input-group compact">
18742 <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);" />
18743 </div>
18744 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
18745 {% else %}
18746 <div class="input-group compact">
18747 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
18748 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
18749 <button type="button" class="mini-button" id="use-default-output">Use default</button>
18750 </div>
18751 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
18752 {% endif %}
18753 </div>
18754 <div class="output-field-aside">
18755 <strong>Where reports land</strong>
18756 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.
18757 </div>
18758 </div>
18759 </div>
18760
18761 <div class="section section-spacer-top">
18762 <div class="output-field-row">
18763 <div class="field">
18764 <label for="report_title">Report title</label>
18765 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
18766 <div class="hint">Appears in HTML and PDF output headers.</div>
18767 </div>
18768 <div class="output-field-aside">
18769 <strong>Shown in exported artifacts</strong>
18770 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.
18771 </div>
18772 </div>
18773 </div>
18774
18775 <div class="section section-spacer-top">
18776 <div class="output-field-row">
18777 <div class="field">
18778 <label for="report_header_footer">Report header / footer</label>
18779 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
18780 <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>
18781 </div>
18782 <div class="output-field-aside">
18783 <strong>Page-level identification</strong>
18784 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.
18785 </div>
18786 </div>
18787 </div>
18788
18789 <div class="wizard-actions">
18790 <div class="left">
18791 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
18792 </div>
18793 <div class="right">
18794 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
18795 </div>
18796 </div>
18797 </div>
18798
18799 <div class="wizard-step" data-step="4">
18800 <div class="section">
18801 <div class="section-kicker">Step 4</div>
18802 <h2>Review selections and run</h2>
18803 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
18804 <div class="review-grid">
18805 <div class="review-card highlight">
18806 <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>
18807 <ul id="review-scan-summary"></ul>
18808 </div>
18809 <div class="review-card highlight">
18810 <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>
18811 <ul id="review-count-summary"></ul>
18812 </div>
18813 <div class="review-card">
18814 <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>
18815 <ul id="review-artifact-summary"></ul>
18816 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
18817 </div>
18818 <div class="review-card">
18819 <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>
18820 <ul id="review-preview-summary"></ul>
18821 </div>
18822 </div>
18823 </div>
18824
18825 <div class="wizard-actions">
18826 <div class="left">
18827 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
18828 </div>
18829 <div class="right">
18830 <button type="submit" id="submit-button" class="primary">Run analysis</button>
18831 </div>
18832 </div>
18833 </div>
18834 {% if server_mode %}
18835 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
18836 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
18837 {% endif %}
18838 </form>
18839 </div>
18840 </section>
18841 </div>
18842 </div>
18843
18844 <script nonce="{{ csp_nonce }}">
18845 (function () {
18846 function startScanPhase() {
18847 var phaseEl = document.getElementById("scan-phase");
18848 if (!phaseEl) return;
18849 var phases = [
18850 "Discovering files...",
18851 "Decoding file encodings...",
18852 "Detecting languages...",
18853 "Analyzing source lines...",
18854 "Applying counting policies...",
18855 "Aggregating results...",
18856 "Rendering report..."
18857 ];
18858 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
18859 var i = 0;
18860 function next() {
18861 phaseEl.style.opacity = "0";
18862 setTimeout(function () {
18863 phaseEl.textContent = phases[i];
18864 phaseEl.style.opacity = "0.85";
18865 var delay = durations[i] || 1800;
18866 i++;
18867 if (i < phases.length) { setTimeout(next, delay); }
18868 }, 200);
18869 }
18870 next();
18871 }
18872
18873 var form = document.getElementById("analyze-form");
18874 var loading = document.getElementById("loading");
18875 var submitButton = document.getElementById("submit-button");
18876 var pathInput = document.getElementById("path");
18877 var GIT_MODE = !!(pathInput && pathInput.readOnly);
18878 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
18879 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
18880 var outputDirInput = document.getElementById("output_dir");
18881 var reportTitleInput = document.getElementById("report_title");
18882 var previewPanel = document.getElementById("preview-panel");
18883 var refreshButton = document.getElementById("refresh-preview");
18884 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
18885 var useSamplePath = document.getElementById("use-sample-path");
18886 var useDefaultOutput = document.getElementById("use-default-output");
18887 var browsePath = document.getElementById("browse-path");
18888 var browseOutputDir = document.getElementById("browse-output-dir");
18889 var browseCoverage = document.getElementById("browse-coverage");
18890 var coverageInput = document.getElementById("coverage_file");
18891 var covScanStatus = document.getElementById("cov-scan-status");
18892 var coverageSuggestTimer = null;
18893 var covAutoFilled = false;
18894 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
18895
18896 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
18897 (function() {
18898 var ids = ["path", "output_dir"];
18899 ids.forEach(function(id) {
18900 var el = document.getElementById(id);
18901 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
18902 });
18903 }());
18904 function fmtBytes(b) {
18905 b = Number(b) || 0;
18906 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
18907 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
18908 if (b >= 1024) return Math.round(b / 1024) + ' KB';
18909 return b + ' B';
18910 }
18911 var themeToggle = document.getElementById("theme-toggle");
18912
18913 function showBannerToast(msg, isError, opts) {
18914 opts = opts || {};
18915 var t = document.createElement('div');
18916 t.className = isError ? 'toast-error' : 'toast-success';
18917 var topPos = opts.top ? '80px' : null;
18918 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
18919 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
18920 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
18921 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
18922 if (opts.icon) {
18923 var inner = document.createElement('span');
18924 inner.innerHTML = opts.icon + ' ';
18925 t.appendChild(inner);
18926 }
18927 t.appendChild(document.createTextNode(msg));
18928 document.body.appendChild(t);
18929 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
18930 }
18931 var mixedLinePolicy = document.getElementById("mixed_line_policy");
18932 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
18933 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
18934 var scanPreset = document.getElementById("scan_preset");
18935 var artifactPreset = document.getElementById("artifact_preset");
18936 var includeGlobsInput = document.getElementById("include_globs");
18937 var excludeGlobsInput = document.getElementById("exclude_globs");
18938
18939 // Include globs scope badge — updates reactively as the user types.
18940 (function() {
18941 var badge = document.getElementById("include-scope-badge");
18942 if (!badge || !includeGlobsInput) return;
18943 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> ';
18944 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> ';
18945 function update() {
18946 var val = includeGlobsInput.value.trim();
18947 if (!val) {
18948 badge.className = "include-scope-badge scope-all";
18949 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
18950 } else {
18951 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
18952 badge.className = "include-scope-badge scope-narrow";
18953 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
18954 }
18955 }
18956 includeGlobsInput.addEventListener("input", update);
18957 update();
18958 }());
18959
18960 // Quick-exclude chips — append pattern to exclude_globs textarea.
18961 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
18962 chip.addEventListener("click", function() {
18963 var pattern = chip.getAttribute("data-pattern") || "";
18964 if (!pattern || !excludeGlobsInput) return;
18965 var current = excludeGlobsInput.value.trim();
18966 // For the "skip all" chip, replace any existing dep patterns cleanly.
18967 var patterns = pattern.split("\n");
18968 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
18969 var added = false;
18970 patterns.forEach(function(p) {
18971 p = p.trim();
18972 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
18973 });
18974 if (added) {
18975 excludeGlobsInput.value = lines.join("\n");
18976 excludeGlobsInput.dispatchEvent(new Event("input"));
18977 }
18978 chip.classList.add("active");
18979 });
18980 });
18981
18982 var liveReportTitle = document.getElementById("live-report-title");
18983 var navProjectPill = document.getElementById("nav-project-pill");
18984 var navProjectTitle = document.getElementById("nav-project-title");
18985 var reportTitlePreview = null;
18986 var wizardProgressFill = document.getElementById("wizard-progress-fill");
18987 var wizardProgressValue = document.getElementById("wizard-progress-value");
18988 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
18989 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
18990 var reportTitleTouched = false;
18991 var currentStep = 1;
18992 var previewTimer = null;
18993 var _previewGen = 0;
18994 // True while the scope preview (local) / project upload (server mode) is in
18995 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
18996 // user can't advance past a project whose scope/upload isn't ready yet.
18997 var previewLoading = false;
18998 function setPreviewLoading(loading) {
18999 previewLoading = !!loading;
19000 var nextBtn = document.getElementById("step1-next");
19001 var gate = document.getElementById("preview-gate-status");
19002 if (nextBtn) {
19003 nextBtn.classList.toggle("is-blocked", previewLoading);
19004 nextBtn.setAttribute("aria-disabled", previewLoading ? "true" : "false");
19005 }
19006 if (gate) {
19007 var txt = gate.querySelector(".preview-gate-text");
19008 if (txt) txt.textContent = SERVER_MODE
19009 ? "Uploading & scanning project…"
19010 : "Scanning project scope…";
19011 gate.style.display = previewLoading ? "flex" : "none";
19012 }
19013 }
19014 // Info button on the gate: scroll up to the live scope preview so the user
19015 // can see exactly what is being scanned (elapsed time + rotating status).
19016 var previewGateInfo = document.getElementById("preview-gate-info");
19017 if (previewGateInfo) {
19018 previewGateInfo.addEventListener("click", function () {
19019 var target = document.getElementById("preview-panel");
19020 if (!target) return;
19021 target.scrollIntoView({ behavior: "smooth", block: "center" });
19022 target.classList.add("preview-panel-flash");
19023 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19024 });
19025 }
19026 var quickScanBtn = document.getElementById("quick-scan-btn");
19027
19028 function dismissAnalysisModal() {
19029 if (loading) loading.classList.remove("active");
19030 document.body.classList.remove("modal-open");
19031 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19032 var el = document.getElementById(id);
19033 if (el) el.classList.add("hidden");
19034 });
19035 var cancelBtn = document.getElementById("lc-cancel-btn");
19036 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19037 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19038 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19039 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19040 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");}
19041 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19042 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19043 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19044 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19045 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19046 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19047 }
19048
19049 var lcDismissBtn = document.getElementById("lc-dismiss");
19050 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19051
19052 // When the browser restores this page from bfcache (Back button after navigating to results),
19053 // the loading overlay would still be showing its active state. Dismiss it immediately.
19054 window.addEventListener("pageshow", function(e) {
19055 if (e.persisted) { dismissAnalysisModal(); }
19056 });
19057
19058 function startAsyncAnalysis(formData) {
19059 var gitRepo = (formData.get("git_repo") || "").toString();
19060 var gitRef = (formData.get("git_ref") || "").toString();
19061 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19062 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19063
19064 var pathEl = document.getElementById("lc-path-text");
19065 if (pathEl) pathEl.textContent = displayPath;
19066
19067 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19068 var el = document.getElementById(id);
19069 if (el) el.classList.add("hidden");
19070 });
19071 var cancelBtn = document.getElementById("lc-cancel-btn");
19072 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19073 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19074 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19075 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19076 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19077 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19078 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19079 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");}
19080 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19081
19082 if (loading) loading.classList.add("active");
19083 document.body.classList.add("modal-open");
19084
19085 var startTime = Date.now();
19086 var elapsedTimer = setInterval(function() {
19087 var s = Math.floor((Date.now() - startTime) / 1000);
19088 var el = document.getElementById("lc-elapsed");
19089 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19090 }, 1000);
19091
19092 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19093
19094 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();}
19095
19096 var PHASE_DESC = {
19097 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19098 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19099 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19100 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19101 'Done': 'Analysis complete \u2014 loading your results\u2026',
19102 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19103 };
19104 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19105 function lcSetPhase(txt) {
19106 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19107 var desc = document.getElementById("lc-stage-desc");
19108 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19109 var step = PHASE_STEP[txt] || 1;
19110 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");}
19111 }
19112
19113 function lcShowCancelled() {
19114 clearInterval(elapsedTimer);
19115 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19116 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19117 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19118 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19119 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19120 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19121 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19122 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19123 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19124 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19125 }
19126
19127 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19128 if (lcCancelBtn) {
19129 lcCancelBtn.onclick = function() {
19130 if (!activeWaitId) { dismissAnalysisModal(); return; }
19131 lcCancelBtn.disabled = true;
19132 lcCancelBtn.textContent = "Cancelling\u2026";
19133 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19134 .then(function() { lcShowCancelled(); })
19135 .catch(function() { lcShowCancelled(); });
19136 };
19137 }
19138
19139 function lcShowError(msg) {
19140 clearInterval(elapsedTimer);
19141 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19142 lcSetPhase("Failed");
19143 var msgEl = document.getElementById("lc-err-msg");
19144 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19145 var errEl = document.getElementById("lc-err");
19146 var actEl = document.getElementById("lc-actions");
19147 if (errEl) errEl.classList.remove("hidden");
19148 if (actEl) actEl.classList.remove("hidden");
19149 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19150 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19151 }
19152
19153 function lcPoll(waitId) {
19154 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19155 .then(function(r) {
19156 if (!r.ok) throw new Error("HTTP " + r.status);
19157 return r.json();
19158 })
19159 .then(function(data) {
19160 pollRetries = 0;
19161 if (data.state === "complete") {
19162 clearInterval(elapsedTimer);
19163 lcSetPhase("Done");
19164 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19165 } else if (data.state === "failed") {
19166 lcShowError(data.message);
19167 } else if (data.state === "cancelled") {
19168 lcShowCancelled();
19169 } else {
19170 var s = Math.floor((Date.now() - startTime) / 1000);
19171 if (s > 90 && !warnShown) {
19172 warnShown = true;
19173 var w = document.getElementById("lc-warn");
19174 if (w) w.classList.remove("hidden");
19175 }
19176 lcSetPhase(data.phase || "Running");
19177 var fd = data.files_done || 0, ft = data.files_total || 0;
19178 if (ft > 0) {
19179 var card = document.getElementById("lc-files-card");
19180 if (card) card.classList.remove("hidden");
19181 var el = document.getElementById("lc-files");
19182 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19183 var now = Date.now();
19184 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19185 if (fdelta > 0 && tdelta > 0.4) {
19186 var fps = Math.round(fdelta / tdelta);
19187 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19188 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19189 }
19190 lastFd = fd; lastFdTime = now;
19191 }
19192 setTimeout(function() { lcPoll(waitId); }, 1500);
19193 }
19194 })
19195 .catch(function() {
19196 pollRetries++;
19197 if (pollRetries >= 5) {
19198 lcShowError("Lost connection to server. Reload to check status.");
19199 } else {
19200 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19201 }
19202 });
19203 }
19204
19205 var params = new URLSearchParams(formData);
19206 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19207 .then(function(r) {
19208 var waitId = r.headers.get("x-wait-id");
19209 if (!waitId) { window.location.href = "/scan"; return; }
19210 activeWaitId = waitId;
19211 setTimeout(function() { lcPoll(waitId); }, 1500);
19212 })
19213 .catch(function(err) {
19214 lcShowError("Could not reach server: " + (err.message || err));
19215 });
19216 }
19217
19218 if (quickScanBtn) {
19219 quickScanBtn.addEventListener("click", function () {
19220 var pathVal = pathInput ? pathInput.value.trim() : "";
19221 if (!pathVal) {
19222 alert("Please enter or browse to a project path first.");
19223 return;
19224 }
19225 quickScanBtn.disabled = true;
19226 quickScanBtn.textContent = "Scanning...";
19227 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19228 startAsyncAnalysis(new FormData(form));
19229 });
19230 }
19231
19232 var mixedPolicyInfo = {
19233 code_only: {
19234 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.",
19235 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'
19236 },
19237 code_and_comment: {
19238 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.",
19239 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'
19240 },
19241 comment_only: {
19242 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.",
19243 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'
19244 },
19245 separate_mixed_category: {
19246 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.",
19247 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'
19248 }
19249 };
19250
19251 var scanPresetInfo = {
19252 balanced: {
19253 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.",
19254 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19255 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19256 note: "Best when you want a stable local overview before making deeper adjustments.",
19257 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19258 },
19259 code_focused: {
19260 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19261 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19262 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19263 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19264 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19265 },
19266 comment_audit: {
19267 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19268 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19269 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19270 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19271 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19272 },
19273 deep_review: {
19274 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19275 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19276 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19277 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19278 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19279 }
19280 };
19281
19282 var artifactPresetInfo = {
19283 review: {
19284 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19285 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19286 example: "Ideal for a quick local review before sharing results."
19287 },
19288 full: {
19289 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19290 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19291 example: "Use when producing a deliverable or storing a snapshot for future comparison."
19292 },
19293 html_only: {
19294 description: "Standalone HTML report only. No PDF generation, no data files.",
19295 chips: ["HTML only"],
19296 example: "Fastest option when you only need to open the report in a browser."
19297 },
19298 machine: {
19299 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19300 chips: ["JSON", "CSV", "no HTML", "no PDF"],
19301 example: "Use in CI to capture metrics without generating visual reports."
19302 }
19303 };
19304
19305 function applyArtifactPreset() {
19306 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19307 if (!info) return;
19308 var descEl = document.getElementById("artifact-preset-description");
19309 var exampleEl = document.getElementById("artifact-preset-example");
19310 if (descEl) descEl.textContent = info.description;
19311 if (exampleEl) exampleEl.textContent = info.example;
19312 renderPresetChips("artifact-preset-summary", info.chips);
19313 }
19314
19315 function applyTheme(theme) {
19316 if (theme === "dark") document.body.classList.add("dark-theme");
19317 else document.body.classList.remove("dark-theme");
19318 }
19319
19320 function loadSavedTheme() {
19321 var saved = null;
19322 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19323 applyTheme(saved === "dark" ? "dark" : "light");
19324 }
19325
19326 function updateScrollProgress() {
19327 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19328 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19329 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19330 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19331 var step = Math.min(Math.max(currentStep, 1), 4);
19332 var base = stepBase[step];
19333 var end = stepEnd[step];
19334
19335 var scrollFrac = 0;
19336 var activePanel = document.querySelector(".wizard-step.active");
19337 if (activePanel) {
19338 var scrollTop = window.scrollY || window.pageYOffset || 0;
19339 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19340 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19341 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19342 var scrolled = scrollTop + viewH - panelTop;
19343 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19344 }
19345
19346 var percent = Math.round(base + (end - base) * scrollFrac);
19347 percent = Math.min(end, Math.max(base, percent));
19348 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19349 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19350 }
19351
19352 function updateWizardProgress() {
19353 updateScrollProgress();
19354 }
19355
19356 var stepDescriptions = [
19357 "Choose a project folder, apply scope filters, and preview which files will be counted.",
19358 "Configure how mixed code-plus-comment lines and docstrings are classified.",
19359 "Pick your output formats, scan preset, and where reports are saved.",
19360 "Review all settings and launch the analysis."
19361 ];
19362
19363 function updateStepNav(step) {
19364 var infoLabel = document.getElementById("step-nav-info-label");
19365 var infoDesc = document.getElementById("step-nav-info-desc");
19366 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19367 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
19368 }
19369
19370 function updateSidebarSummary() {
19371 var sumPath = document.getElementById("sum-path");
19372 var sumPreset = document.getElementById("sum-preset");
19373 var sumOutput = document.getElementById("sum-output");
19374 var sidebarSummary = document.getElementById("sidebar-summary");
19375 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19376 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
19377 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19378 if (sumPath) sumPath.textContent = pathVal || "\u2014";
19379 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19380 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19381 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19382 }
19383
19384 function setStep(step, pushHistory) {
19385 currentStep = step;
19386 stepPanels.forEach(function (panel) {
19387 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19388 });
19389 stepButtons.forEach(function (button) {
19390 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19391 });
19392 var layoutEl = document.querySelector(".layout");
19393 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19394 updateWizardProgress();
19395 updateStepNav(step);
19396 stepButtons.forEach(function(btn) {
19397 var t = Number(btn.getAttribute("data-step-target"));
19398 btn.classList.toggle("done", t < step);
19399 });
19400 updateSidebarSummary();
19401
19402 if (pushHistory !== false) {
19403 try {
19404 history.pushState({ wizardStep: step }, "", "#step" + step);
19405 } catch (e) {}
19406 }
19407
19408 window.scrollTo({ top: 0, behavior: "instant" });
19409 }
19410
19411 window.addEventListener("popstate", function (e) {
19412 if (e.state && e.state.wizardStep) {
19413 setStep(e.state.wizardStep, false);
19414 } else {
19415 var hashMatch = location.hash.match(/^#step([1-4])$/);
19416 if (hashMatch) setStep(Number(hashMatch[1]), false);
19417 }
19418 });
19419
19420 function inferTitleFromPath(value) {
19421 if (!value) return "project";
19422 var cleaned = value.replace(/[\/\\]+$/, "");
19423 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19424 return parts.length ? parts[parts.length - 1] : value;
19425 }
19426
19427 function updateReportTitleFromPath() {
19428 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19429 if (!reportTitleTouched) {
19430 reportTitleInput.value = inferred;
19431 }
19432 var title = reportTitleInput.value || inferred;
19433 if (liveReportTitle) liveReportTitle.textContent = title;
19434 if (reportTitlePreview) reportTitlePreview.textContent = title;
19435 document.title = "OxideSLOC | " + title;
19436
19437 var projectPath = (pathInput.value || "").trim();
19438 if (navProjectPill && navProjectTitle) {
19439 if (projectPath.length > 0) {
19440 navProjectTitle.textContent = inferred;
19441 navProjectPill.classList.add("visible");
19442 } else {
19443 navProjectTitle.textContent = "";
19444 navProjectPill.classList.remove("visible");
19445 }
19446 }
19447 }
19448
19449 function updateMixedPolicyUI() {
19450 var key = mixedLinePolicy.value || "code_only";
19451 var info = mixedPolicyInfo[key];
19452 document.getElementById("mixed-policy-description").textContent = info.description;
19453 document.getElementById("mixed-policy-example").textContent = info.example;
19454 }
19455
19456 function updatePythonDocstringUI() {
19457 var checked = !!pythonDocstrings.checked;
19458 document.getElementById("python-docstring-example").textContent = checked
19459 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
19460 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
19461 document.getElementById("python-docstring-live-help").textContent = checked
19462 ? "Enabled: docstrings contribute to comment-style totals."
19463 : "Disabled: docstrings are not counted as comment content.";
19464 }
19465
19466 function renderPresetChips(targetId, chips) {
19467 var target = document.getElementById(targetId);
19468 if (!target) return;
19469 target.innerHTML = (chips || []).map(function (chip) {
19470 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19471 }).join('');
19472 }
19473
19474 function updatePresetDescriptions() {
19475 var scanInfo = scanPresetInfo[scanPreset.value];
19476 if (!scanInfo) return;
19477 document.getElementById("scan-preset-description").textContent = scanInfo.description;
19478 document.getElementById("scan-preset-example").textContent = scanInfo.example;
19479 document.getElementById("scan-preset-note").textContent = scanInfo.note;
19480 renderPresetChips("scan-preset-summary", scanInfo.chips);
19481 }
19482
19483 function applyScanPreset() {
19484 var info = scanPresetInfo[scanPreset.value];
19485 if (!info || !info.apply) return;
19486 mixedLinePolicy.value = info.apply.mixed;
19487 pythonDocstrings.checked = !!info.apply.docstrings;
19488 document.getElementById("generated_file_detection").value = info.apply.generated;
19489 document.getElementById("minified_file_detection").value = info.apply.minified;
19490 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19491 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19492 document.getElementById("binary_file_behavior").value = info.apply.binary;
19493 updateMixedPolicyUI();
19494 updatePythonDocstringUI();
19495 }
19496
19497 function updateReview() {
19498 var scanSummary = document.getElementById("review-scan-summary");
19499 var countSummary = document.getElementById("review-count-summary");
19500 var artifactSummary = document.getElementById("review-artifact-summary");
19501 var outputSummary = document.getElementById("review-output-summary");
19502 var previewSummary = document.getElementById("review-preview-summary");
19503 var readinessSummary = document.getElementById("review-readiness-summary");
19504 var includeText = document.getElementById("include_globs").value.trim();
19505 var excludeText = document.getElementById("exclude_globs").value.trim();
19506 var sidePathPreview = document.getElementById("side-path-preview");
19507 var sideOutputPreview = document.getElementById("side-output-preview");
19508 var sideTitlePreview = document.getElementById("side-title-preview");
19509
19510 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19511 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19512 if (sideTitlePreview) {
19513 var rt = document.getElementById("report_title");
19514 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19515 }
19516
19517 scanSummary.innerHTML = ""
19518 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19519 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19520 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19521
19522 countSummary.innerHTML = ""
19523 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19524 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19525 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19526 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19527 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19528 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19529 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19530 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19531
19532 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19533
19534 outputSummary.innerHTML = ""
19535 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19536 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19537
19538 if (previewSummary) {
19539 if (GIT_MODE) {
19540 previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
19541 } else {
19542 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19543 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19544 var statMap = {};
19545 statButtons.forEach(function (button) {
19546 var valueNode = button.querySelector('.scope-stat-value');
19547 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19548 });
19549 previewSummary.innerHTML = ''
19550 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19551 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19552 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19553 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19554 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19555 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19556
19557 if (readinessSummary) {
19558 readinessSummary.innerHTML = ''
19559 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19560 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19561 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19562 }
19563 } // end else (non-GIT_MODE)
19564 }
19565 }
19566
19567 function escapeHtml(value) {
19568 return String(value)
19569 .replace(/&/g, "&")
19570 .replace(/</g, "<")
19571 .replace(/>/g, ">")
19572 .replace(/"/g, """)
19573 .replace(/'/g, "'");
19574 }
19575
19576 function isPythonVisible() {
19577 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19578 }
19579
19580 function syncPythonVisibility() {
19581 var html = previewPanel.textContent || "";
19582 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19583 pythonWraps.forEach(function (node) {
19584 node.classList.toggle("hidden", !hasPython);
19585 });
19586 }
19587
19588 function attachPreviewInteractions() {
19589 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19590 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19591 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19592 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19593 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19594 var searchInput = previewPanel.querySelector("#explorer-search");
19595 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19596 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19597 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19598 var activeFilter = "all";
19599 var activeLanguage = "";
19600 var searchTerm = "";
19601 var currentSortKey = null;
19602 var currentSortOrder = "asc";
19603 var childRows = {};
19604
19605 rows.forEach(function (row) {
19606 var parentId = row.getAttribute("data-parent-id") || "";
19607 var rowId = row.getAttribute("data-row-id") || "";
19608 if (!childRows[parentId]) childRows[parentId] = [];
19609 childRows[parentId].push(rowId);
19610 });
19611
19612 function rowById(id) {
19613 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
19614 }
19615
19616 function hasCollapsedAncestor(row) {
19617 var parentId = row.getAttribute("data-parent-id");
19618 while (parentId) {
19619 var parent = rowById(parentId);
19620 if (!parent) break;
19621 if (parent.getAttribute("data-expanded") === "false") return true;
19622 parentId = parent.getAttribute("data-parent-id");
19623 }
19624 return false;
19625 }
19626
19627 function updateToggleGlyph(row) {
19628 var toggle = row.querySelector(".tree-toggle");
19629 if (!toggle) return;
19630 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
19631 }
19632
19633 function rowSortValue(row, key) {
19634 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
19635 }
19636
19637 function updateSortButtons() {
19638 sortButtons.forEach(function (button) {
19639 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
19640 var indicator = button.querySelector(".tree-sort-indicator");
19641 button.classList.toggle("active", isActive);
19642 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
19643 if (indicator) {
19644 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
19645 }
19646 });
19647 }
19648
19649 function sortSiblingRows() {
19650 if (!treeContainer) {
19651 updateSortButtons();
19652 return;
19653 }
19654
19655 var rowMap = {};
19656 var childrenMap = {};
19657 rows.forEach(function (row) {
19658 var rowId = row.getAttribute("data-row-id");
19659 var parentId = row.getAttribute("data-parent-id") || "";
19660 rowMap[rowId] = row;
19661 if (!childrenMap[parentId]) childrenMap[parentId] = [];
19662 childrenMap[parentId].push(rowId);
19663 });
19664
19665 Object.keys(childrenMap).forEach(function (parentId) {
19666 if (!parentId) return;
19667 childrenMap[parentId].sort(function (a, b) {
19668 var rowA = rowMap[a];
19669 var rowB = rowMap[b];
19670 if (!currentSortKey) {
19671 return Number(a) - Number(b);
19672 }
19673 var valueA = rowSortValue(rowA, currentSortKey);
19674 var valueB = rowSortValue(rowB, currentSortKey);
19675 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
19676 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
19677 var fallbackA = rowSortValue(rowA, "name");
19678 var fallbackB = rowSortValue(rowB, "name");
19679 if (fallbackA < fallbackB) return -1;
19680 if (fallbackA > fallbackB) return 1;
19681 return Number(a) - Number(b);
19682 });
19683 });
19684
19685 var orderedIds = [];
19686 function pushChildren(parentId) {
19687 (childrenMap[parentId] || []).forEach(function (childId) {
19688 orderedIds.push(childId);
19689 pushChildren(childId);
19690 });
19691 }
19692
19693 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
19694 orderedIds.push(topId);
19695 pushChildren(topId);
19696 });
19697
19698 orderedIds.forEach(function (id) {
19699 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
19700 });
19701 updateSortButtons();
19702 }
19703
19704 function updateLanguageButtons() {
19705 languageButtons.forEach(function (button) {
19706 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
19707 var isActive = languageValue === activeLanguage;
19708 button.classList.toggle("active", isActive);
19709 });
19710 }
19711
19712 function rowSelfMatches(row) {
19713 var kind = row.getAttribute("data-kind");
19714 var status = row.getAttribute("data-status");
19715 var language = (row.getAttribute("data-language") || "").toLowerCase();
19716 var name = row.getAttribute("data-name-lower") || "";
19717 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
19718 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
19719 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
19720 var passesLanguage = !activeLanguage || language === activeLanguage;
19721 return passesFilter && passesSearch && passesLanguage;
19722 }
19723
19724 function hasMatchingDescendant(rowId) {
19725 return (childRows[rowId] || []).some(function (childId) {
19726 var childRow = rowById(childId);
19727 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
19728 });
19729 }
19730
19731 function rowMatches(row) {
19732 if (rowSelfMatches(row)) return true;
19733 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
19734 }
19735
19736 function resetViewState() {
19737 activeFilter = "all";
19738 activeLanguage = "";
19739 searchTerm = "";
19740 currentSortKey = null;
19741 currentSortOrder = "asc";
19742 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19743 if (searchInput) searchInput.value = "";
19744 if (filterSelect) filterSelect.value = "all";
19745 updateLanguageButtons();
19746 }
19747
19748 function applyVisibility() {
19749 rows.forEach(function (row) {
19750 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
19751 row.classList.toggle("hidden-by-filter", !visible);
19752 row.style.display = visible ? "grid" : "none";
19753 });
19754 buttons.forEach(function (button) {
19755 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
19756 });
19757 if (filterSelect) filterSelect.value = activeFilter;
19758 }
19759
19760 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
19761 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
19762 var originalStats = {};
19763 buttons.forEach(function (btn) {
19764 var f = btn.getAttribute('data-filter');
19765 var v = btn.querySelector('.scope-stat-value');
19766 if (f && v) originalStats[f] = v.textContent;
19767 });
19768
19769 function applySubmoduleStats(statsJson) {
19770 try {
19771 var s = JSON.parse(statsJson);
19772 buttons.forEach(function (btn) {
19773 var f = btn.getAttribute('data-filter');
19774 var v = btn.querySelector('.scope-stat-value');
19775 if (!v) return;
19776 if (f === 'dir') v.textContent = s.dirs;
19777 else if (f === 'file') v.textContent = s.files;
19778 else if (f === 'supported') v.textContent = s.supported;
19779 else if (f === 'skipped') v.textContent = s.skipped;
19780 else if (f === 'unsupported') v.textContent = s.unsupported;
19781 });
19782 } catch (e) {}
19783 }
19784
19785 function restoreBaseRepoStats() {
19786 buttons.forEach(function (btn) {
19787 var f = btn.getAttribute('data-filter');
19788 var v = btn.querySelector('.scope-stat-value');
19789 if (v && originalStats[f]) v.textContent = originalStats[f];
19790 });
19791 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19792 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
19793 }
19794
19795 submoduleChips.forEach(function (chip) {
19796 chip.addEventListener('click', function () {
19797 var statsJson = chip.getAttribute('data-sub-stats');
19798 if (!statsJson) return;
19799 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19800 chip.classList.add('active');
19801 applySubmoduleStats(statsJson);
19802 if (baseRepoBtn) baseRepoBtn.style.display = '';
19803 });
19804 });
19805
19806 if (baseRepoBtn) {
19807 baseRepoBtn.addEventListener('click', function () {
19808 restoreBaseRepoStats();
19809 resetViewState();
19810 sortSiblingRows();
19811 applyVisibility();
19812 });
19813 }
19814
19815 buttons.forEach(function (button) {
19816 button.addEventListener("click", function () {
19817 var filterValue = button.getAttribute("data-filter") || "all";
19818 if (filterValue === "reset-view") {
19819 restoreBaseRepoStats();
19820 resetViewState();
19821 sortSiblingRows();
19822 applyVisibility();
19823 return;
19824 }
19825 activeFilter = filterValue;
19826 applyVisibility();
19827 });
19828 });
19829
19830 rows.forEach(function (row) {
19831 updateToggleGlyph(row);
19832 var toggle = row.querySelector(".tree-toggle");
19833 if (toggle) {
19834 toggle.addEventListener("click", function () {
19835 var expanded = row.getAttribute("data-expanded") !== "false";
19836 row.setAttribute("data-expanded", expanded ? "false" : "true");
19837 updateToggleGlyph(row);
19838 applyVisibility();
19839 });
19840 }
19841 });
19842
19843 actionButtons.forEach(function (button) {
19844 button.addEventListener("click", function () {
19845 var action = button.getAttribute("data-explorer-action");
19846 if (action === "expand-all") {
19847 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19848 } else if (action === "collapse-all") {
19849 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
19850 } else if (action === "clear-filters") {
19851 resetViewState();
19852 }
19853 sortSiblingRows();
19854 applyVisibility();
19855 });
19856 });
19857
19858 if (filterSelect) {
19859 filterSelect.addEventListener("change", function () {
19860 activeFilter = filterSelect.value || "all";
19861 applyVisibility();
19862 });
19863 }
19864
19865 languageButtons.forEach(function (button) {
19866 button.addEventListener("click", function () {
19867 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
19868 updateLanguageButtons();
19869 applyVisibility();
19870 });
19871 });
19872
19873 sortButtons.forEach(function (button) {
19874 button.addEventListener("click", function () {
19875 var sortKey = button.getAttribute("data-sort-key");
19876 if (currentSortKey === sortKey) {
19877 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
19878 } else {
19879 currentSortKey = sortKey;
19880 currentSortOrder = "asc";
19881 }
19882 sortSiblingRows();
19883 applyVisibility();
19884 });
19885 });
19886
19887 if (searchInput) {
19888 searchInput.addEventListener("input", function () {
19889 searchTerm = searchInput.value.trim().toLowerCase();
19890 applyVisibility();
19891 });
19892 }
19893
19894 updateLanguageButtons();
19895 sortSiblingRows();
19896 applyVisibility();
19897 }
19898
19899 function loadPreview() {
19900 if (!previewPanel || !pathInput) return;
19901 if (GIT_MODE) {
19902 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>';
19903 setPreviewLoading(false);
19904 return;
19905 }
19906 var path = pathInput.value.trim();
19907 var zeroWarn = document.getElementById('zero-files-warning');
19908 if (!path) {
19909 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
19910 if (zeroWarn) zeroWarn.style.display = 'none';
19911 setPreviewLoading(false);
19912 return;
19913 }
19914 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
19915 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
19916 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
19917 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
19918 var myGen = ++_previewGen;
19919 var _prevMsgs = [
19920 'Scanning directory structure\u2026',
19921 'Detecting file types\u2026',
19922 'Applying include / exclude filters\u2026',
19923 'Estimating file counts\u2026',
19924 'Building scope preview\u2026',
19925 'Almost there\u2026'
19926 ];
19927 var _prevMsgIdx = 0;
19928 var _prevStart = Date.now();
19929 previewPanel.innerHTML =
19930 '<div class="preview-loading">' +
19931 '<div class="preview-spinner"></div>' +
19932 '<div class="preview-loading-text">' +
19933 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
19934 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
19935 '</div></div>';
19936 var _sizeTextEl = document.getElementById('project-size-text');
19937 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
19938 window._previewInterval = setInterval(function() {
19939 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
19940 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
19941 var ml = document.getElementById('plm');
19942 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
19943 }, 1500);
19944 window._previewElapsedTimer = setInterval(function() {
19945 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
19946 var el = document.getElementById('ple');
19947 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
19948 }, 1000);
19949 setPreviewLoading(true);
19950 var previewUrl = "/preview?path=" + encodeURIComponent(path)
19951 + "&include_globs=" + encodeURIComponent(includeValue)
19952 + "&exclude_globs=" + encodeURIComponent(excludeValue);
19953 fetch(previewUrl)
19954 .then(function (response) { return response.text(); })
19955 .then(function (html) {
19956 if (myGen !== _previewGen) return;
19957 clearInterval(window._previewInterval); window._previewInterval = null;
19958 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
19959 setPreviewLoading(false);
19960 previewPanel.innerHTML = html;
19961 attachPreviewInteractions();
19962 syncPythonVisibility();
19963 updateReview();
19964 setTimeout(collapseLanguagePills, 50);
19965 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
19966 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
19967 var sizeText = document.getElementById('project-size-text');
19968 var sizeBtn = document.getElementById('project-size-btn');
19969 // In server mode with upload sizes available, keep the compressed/original pair.
19970 if (SERVER_MODE && window._lastUploadSizes) {
19971 var us = window._lastUploadSizes;
19972 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
19973 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
19974 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
19975 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
19976 } else if (sizeText && projectSize) {
19977 sizeText.textContent = 'Project size: ' + projectSize;
19978 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
19979 } else if (sizeText) {
19980 sizeText.textContent = 'Project size: \u2014';
19981 }
19982 if (zeroWarn) {
19983 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
19984 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
19985 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
19986 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
19987 if (supportedCount === 0 && fileCount > 0) {
19988 zeroWarn.textContent = '\u26a0 Warning: No supported source files detected\u2014this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
19989 zeroWarn.style.display = '';
19990 } else {
19991 zeroWarn.style.display = 'none';
19992 }
19993 }
19994 })
19995 .catch(function (err) {
19996 if (myGen !== _previewGen) return;
19997 clearInterval(window._previewInterval); window._previewInterval = null;
19998 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
19999 setPreviewLoading(false);
20000 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20001 });
20002 }
20003
20004 function pickDirectory(targetInput, kind) {
20005 if (!targetInput) {
20006 showBannerToast("Directory picker: input element not found.", true);
20007 return;
20008 }
20009 if (SERVER_MODE) {
20010 if (kind === 'output') {
20011 showBannerToast(
20012 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20013 false,
20014 { top: true, icon: '\u{1F4C1}' }
20015 );
20016 return;
20017 }
20018 var inputEl = kind === 'coverage'
20019 ? document.getElementById('cov-upload-input')
20020 : document.getElementById('dir-upload-input');
20021 if (!inputEl) return;
20022 inputEl.onchange = function () {
20023 var files = inputEl.files;
20024 if (!files || files.length === 0) return;
20025 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20026 if (browseBtn) browseBtn.disabled = true;
20027
20028 function fileToBase64(file) {
20029 return new Promise(function (resolve, reject) {
20030 var reader = new FileReader();
20031 reader.onload = function () {
20032 var b64 = reader.result.split(',')[1];
20033 resolve(b64);
20034 };
20035 reader.onerror = reject;
20036 reader.readAsDataURL(file);
20037 });
20038 }
20039
20040 if (kind === 'coverage') {
20041 var f = files[0];
20042 if (previewPanel && targetInput === pathInput)
20043 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20044 fileToBase64(f).then(function (b64) {
20045 return fetch('/api/upload-file', {
20046 method: 'POST',
20047 headers: { 'Content-Type': 'application/json' },
20048 body: JSON.stringify({ filename: f.name, content: b64 })
20049 }).then(function (r) { return r.json(); });
20050 })
20051 .then(function (d) {
20052 if (d && d.tmp_path) {
20053 if (coverageInput) coverageInput.value = d.tmp_path;
20054 setCovStatus('idle');
20055 } else if (d && d.error) { showBannerToast(d.error, true); }
20056 })
20057 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20058 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20059 } else {
20060 // ── Filter to source-code files only ─────────────────────────
20061 // Binary, generated, and dependency files (node_modules, .git,
20062 // build artifacts) are skipped so they are never uploaded.
20063 var CODE_EXTS = new Set([
20064 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20065 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20066 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20067 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20068 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20069 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20070 'tf','hcl','proto','thrift','avsc','graphql','gql'
20071 ]);
20072 var codeFiles = [];
20073 for (var i = 0; i < files.length; i++) {
20074 var f = files[i];
20075 var name = f.name;
20076 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20077 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20078 codeFiles.push(f); continue;
20079 }
20080 var dot = name.lastIndexOf('.');
20081 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20082 }
20083 // Collect specific .git metadata files for server-side git detection.
20084 // These have no source extension so they are excluded by the loop above,
20085 // but the server needs them to read branch/commit/author without running git.
20086 var gitMetaFiles = [];
20087 for (var i = 0; i < files.length; i++) {
20088 var f = files[i];
20089 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20090 var gitIdx = rp.indexOf('/.git/');
20091 if (gitIdx < 0) continue;
20092 var gitRel = rp.slice(gitIdx + 1);
20093 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20094 gitRel === '.git/logs/HEAD' ||
20095 gitRel.startsWith('.git/refs/heads/') ||
20096 gitRel.startsWith('.git/refs/tags/')) {
20097 gitMetaFiles.push(f);
20098 }
20099 }
20100 var uploadFiles = codeFiles.concat(gitMetaFiles);
20101 var total = files.length;
20102 var kept = codeFiles.length;
20103 if (kept === 0) {
20104 if (previewPanel && targetInput === pathInput)
20105 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20106 if (browseBtn) browseBtn.disabled = false;
20107 inputEl.value = '';
20108 return;
20109 }
20110
20111 // ── Helper: apply upload result to UI ────────────────────────
20112 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20113 function applyUploadResult(tmpPath, sizes) {
20114 targetInput.value = tmpPath;
20115 scrollInputToEnd(targetInput);
20116 if (sizes && SERVER_MODE) {
20117 window._lastUploadSizes = sizes;
20118 // Immediately show both sizes before preview loads.
20119 var sizeText = document.getElementById('project-size-text');
20120 var sizeBtn = document.getElementById('project-size-btn');
20121 if (sizeText) {
20122 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20123 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20124 }
20125 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20126 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20127 }
20128 if (targetInput === pathInput) {
20129 updateReportTitleFromPath();
20130 autoSetOutputDir(tmpPath);
20131 fetchProjectHistory(tmpPath);
20132 loadPreview();
20133 suggestCoverageFile(tmpPath);
20134 }
20135 updateReview();
20136 if (browseBtn) browseBtn.disabled = false;
20137 inputEl.value = '';
20138 }
20139
20140 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20141 if (typeof CompressionStream !== 'undefined') {
20142 if (previewPanel && targetInput === pathInput)
20143 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20144
20145 // Build a minimal POSIX ustar tar header for a single file entry.
20146 function buildUstarHeader(filePath, fileSize) {
20147 var BLOCK = 512;
20148 var hdr = new Uint8Array(BLOCK);
20149 var enc = new TextEncoder();
20150 function wStr(off, len, s) {
20151 var b = enc.encode(s);
20152 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20153 }
20154 function wOct(off, len, val) {
20155 var s = val.toString(8);
20156 while (s.length < len - 1) s = '0' + s;
20157 wStr(off, len, s + '\0');
20158 }
20159 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20160 var name = filePath, prefix = '';
20161 if (filePath.length > 99) {
20162 var split = filePath.lastIndexOf('/', 154);
20163 if (split > 0 && filePath.length - split - 1 <= 99) {
20164 prefix = filePath.substring(0, split);
20165 name = filePath.substring(split + 1);
20166 } else { name = filePath.substring(0, 99); }
20167 }
20168 wStr(0, 100, name); // name
20169 wOct(100, 8, 0o000644); // mode
20170 wOct(108, 8, 0); // uid
20171 wOct(116, 8, 0); // gid
20172 wOct(124, 12, fileSize); // size
20173 wOct(136, 12, 0); // mtime (epoch)
20174 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20175 hdr[156] = 48; // type flag '0' = regular file
20176 wStr(157, 100, ''); // linkname
20177 wStr(257, 6, 'ustar'); // magic
20178 wStr(263, 2, '00'); // version
20179 wStr(265, 32, ''); // uname
20180 wStr(297, 32, ''); // gname
20181 wOct(329, 8, 0); // devmajor
20182 wOct(337, 8, 0); // devminor
20183 wStr(345, 155, prefix); // prefix
20184 // Compute checksum (sum of all bytes, placeholder = 32).
20185 var chk = 0;
20186 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20187 var cs = chk.toString(8);
20188 while (cs.length < 6) cs = '0' + cs;
20189 wStr(148, 8, cs + '\0 ');
20190 return hdr;
20191 }
20192
20193 // Build tar.gz one file at a time, piping through CompressionStream.
20194 // RAM usage = compressed output buffer + one file at a time.
20195 (async function () {
20196 try {
20197 var BLOCK = 512;
20198 var cs = new CompressionStream('gzip');
20199 var writer = cs.writable.getWriter();
20200 var chunks = [];
20201 var reader = cs.readable.getReader();
20202 var collecting = (async function () {
20203 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20204 })();
20205
20206 for (var i = 0; i < uploadFiles.length; i++) {
20207 var file = uploadFiles[i];
20208 var path = file.webkitRelativePath || file.name;
20209 var buf = await file.arrayBuffer();
20210 var data = new Uint8Array(buf);
20211 // Header block
20212 await writer.write(buildUstarHeader(path, data.length));
20213 // Data padded to 512-byte boundary
20214 if (data.length > 0) {
20215 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20216 var block = new Uint8Array(padded);
20217 block.set(data);
20218 await writer.write(block);
20219 }
20220 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20221 if (previewPanel && targetInput === pathInput)
20222 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20223 }
20224 }
20225 // End-of-archive: two 512-byte zero blocks
20226 await writer.write(new Uint8Array(BLOCK * 2));
20227 await writer.close();
20228 await collecting;
20229
20230 var blob = new Blob(chunks, { type: 'application/gzip' });
20231 var sizeMB = (blob.size / 1048576).toFixed(1);
20232 if (previewPanel && targetInput === pathInput)
20233 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20234
20235 var resp = await fetch('/api/upload-tarball', {
20236 method: 'POST',
20237 headers: { 'Content-Type': 'application/gzip' },
20238 body: blob
20239 });
20240 var d = await resp.json();
20241 if (d && d.tmp_path) {
20242 applyUploadResult(d.tmp_path, {
20243 compressed_bytes: d.compressed_bytes || 0,
20244 original_bytes: d.original_bytes || 0
20245 });
20246 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20247 } catch (e) {
20248 showBannerToast('Upload failed: ' + String(e), true);
20249 if (browseBtn) browseBtn.disabled = false;
20250 inputEl.value = '';
20251 }
20252 })();
20253
20254 } else {
20255 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20256 // Used only on browsers that lack CompressionStream (pre-2023).
20257 var BATCH = 200;
20258 var batches = [];
20259 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20260 var totalBatches = batches.length;
20261 if (previewPanel && targetInput === pathInput)
20262 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20263
20264 function sendBatch(idx, currentUploadId, lastTmpPath) {
20265 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20266 if (previewPanel && targetInput === pathInput && totalBatches > 1)
20267 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20268 Promise.all(batches[idx].map(function (file) {
20269 return fileToBase64(file).then(function (b64) {
20270 return { path: file.webkitRelativePath || file.name, content: b64 };
20271 });
20272 })).then(function (fileList) {
20273 var body = { files: fileList };
20274 if (currentUploadId) body.upload_id = currentUploadId;
20275 return fetch('/api/upload-directory', {
20276 method: 'POST', headers: { 'Content-Type': 'application/json' },
20277 body: JSON.stringify(body)
20278 }).then(function (r) { return r.json(); });
20279 }).then(function (d) {
20280 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20281 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20282 }).catch(function (e) {
20283 showBannerToast('Upload failed: ' + String(e), true);
20284 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20285 });
20286 }
20287 sendBatch(0, null, '');
20288 }
20289 }
20290 };
20291 inputEl.click();
20292 return;
20293 }
20294
20295 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20296 if (browseButton) browseButton.disabled = true;
20297
20298 if (previewPanel && targetInput === pathInput) {
20299 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20300 }
20301
20302 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
20303 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20304 .then(function (data) {
20305 if (data && data.selected_path) {
20306 targetInput.value = data.selected_path;
20307 scrollInputToEnd(targetInput);
20308
20309 if (targetInput === pathInput) {
20310 updateReportTitleFromPath();
20311 autoSetOutputDir(data.selected_path);
20312 fetchProjectHistory(data.selected_path);
20313 loadPreview();
20314 suggestCoverageFile(data.selected_path);
20315 }
20316
20317 updateReview();
20318 } else if (targetInput === pathInput) {
20319 loadPreview();
20320 }
20321 })
20322 .catch(function () {
20323 window.alert("Directory picker request failed.");
20324 if (previewPanel && targetInput === pathInput) {
20325 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20326 }
20327 })
20328 .finally(function () {
20329 if (browseButton) browseButton.disabled = false;
20330 });
20331 }
20332
20333 if (themeToggle) {
20334 themeToggle.addEventListener("click", function () {
20335 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20336 applyTheme(nextTheme);
20337 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20338 });
20339 }
20340
20341 stepButtons.forEach(function (button) {
20342 button.addEventListener("click", function () {
20343 var target = Number(button.getAttribute("data-step-target"));
20344 // Block jumping forward off step 1 while the preview / upload is running.
20345 if (previewLoading && currentStep === 1 && target > 1) return;
20346 setStep(target);
20347 });
20348 });
20349
20350 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20351 button.addEventListener("click", function () {
20352 var target = Number(button.getAttribute("data-step-target")) || 1;
20353 if (previewLoading && currentStep === 1 && target > 1) return;
20354 setStep(target);
20355 });
20356 });
20357
20358 // True when the project path is untouched from the bundled sample default.
20359 function isDefaultSamplePath() {
20360 return !GIT_MODE && pathInput && pathInput.value.trim() === "tests/fixtures/basic";
20361 }
20362
20363 var defaultPathOverlay = document.getElementById("default-path-overlay");
20364 function closeDefaultPathModal() {
20365 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20366 }
20367 function openDefaultPathModal() {
20368 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20369 }
20370
20371 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20372 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20373 // that borrow the .next-step style class but carry no data-next target).
20374 if (!button.hasAttribute("data-next")) return;
20375 button.addEventListener("click", function () {
20376 // Guard step 1 → 2: block while the scope preview / upload is still running.
20377 if (button.getAttribute("data-next") === "2" && previewLoading) return;
20378 // Guard step 1 → 2: warn when the project path is still the sample default.
20379 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20380 openDefaultPathModal();
20381 return;
20382 }
20383 updateReview();
20384 setStep(Number(button.getAttribute("data-next")));
20385 });
20386 });
20387
20388 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20389 if (!button.hasAttribute("data-prev")) return;
20390 button.addEventListener("click", function () {
20391 setStep(Number(button.getAttribute("data-prev")));
20392 });
20393 });
20394
20395 // Default-sample-path confirmation modal wiring.
20396 var defaultPathProceed = document.getElementById("default-path-proceed");
20397 if (defaultPathProceed) {
20398 defaultPathProceed.addEventListener("click", function () {
20399 closeDefaultPathModal();
20400 updateReview();
20401 setStep(2);
20402 });
20403 }
20404 var defaultPathCancel = document.getElementById("default-path-cancel");
20405 if (defaultPathCancel) {
20406 defaultPathCancel.addEventListener("click", function () {
20407 closeDefaultPathModal();
20408 if (pathInput) { pathInput.focus(); pathInput.select(); }
20409 });
20410 }
20411 if (defaultPathOverlay) {
20412 defaultPathOverlay.addEventListener("click", function (e) {
20413 if (e.target === defaultPathOverlay) closeDefaultPathModal();
20414 });
20415 }
20416 document.addEventListener("keydown", function (e) {
20417 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20418 closeDefaultPathModal();
20419 }
20420 });
20421
20422 document.addEventListener("keydown", function (e) {
20423 var tag = (document.activeElement || {}).tagName || "";
20424 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20425 if (e.altKey || e.ctrlKey || e.metaKey) return;
20426 if (e.key === "ArrowRight" && currentStep < 4) {
20427 if (currentStep === 1 && previewLoading) return;
20428 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20429 updateReview(); setStep(currentStep + 1);
20430 }
20431 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20432 });
20433
20434 if (useSamplePath) {
20435 useSamplePath.addEventListener("click", function () {
20436 pathInput.value = "tests/fixtures/basic";
20437 updateReportTitleFromPath();
20438 autoSetOutputDir("tests/fixtures/basic");
20439 loadPreview();
20440 suggestCoverageFile("tests/fixtures/basic");
20441 });
20442 }
20443
20444 if (useDefaultOutput) {
20445 useDefaultOutput.addEventListener("click", function () {
20446 delete outputDirInput.dataset.userEdited;
20447 autoSetOutputDir(pathInput ? pathInput.value : "");
20448 updateReview();
20449 });
20450 }
20451
20452 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20453 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20454
20455 // ── Drag-and-drop directory upload (server mode only) ─────────────────
20456 // Dropping a folder onto the path field bypasses Chrome's
20457 // "Upload X files to this site?" confirmation dialog.
20458 async function readDirRecursively(dirEntry, basePath) {
20459 var reader = dirEntry.createReader();
20460 var all = [];
20461 for (;;) {
20462 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20463 if (!batch.length) break;
20464 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20465 }
20466 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20467 var out = [];
20468 for (var i = 0; i < all.length; i++) {
20469 var sub = all[i];
20470 if (sub.isFile) {
20471 var f = await new Promise(function(res) { sub.file(res); });
20472 out.push({ file: f, path: basePath + '/' + sub.name });
20473 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20474 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20475 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20476 }
20477 }
20478 return out;
20479 }
20480
20481 function setupPathDropZone() {
20482 if (!SERVER_MODE || !pathInput) return;
20483 var CODE_EXTS = new Set([
20484 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20485 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20486 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20487 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20488 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20489 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20490 ]);
20491 pathInput.addEventListener('dragover', function(e) {
20492 e.preventDefault();
20493 pathInput.classList.add('drag-over');
20494 });
20495 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20496 pathInput.addEventListener('drop', function(e) {
20497 e.preventDefault();
20498 pathInput.classList.remove('drag-over');
20499 var items = e.dataTransfer.items;
20500 if (!items || !items.length) return;
20501 var dirEntry = null;
20502 for (var i = 0; i < items.length; i++) {
20503 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20504 if (entry && entry.isDirectory) { dirEntry = entry; break; }
20505 }
20506 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20507 var btn = browsePath;
20508 if (btn) btn.disabled = true;
20509 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20510
20511 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20512 var total = allEntries.length;
20513 var codeEntries = allEntries.filter(function(e) {
20514 var n = e.file.name;
20515 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20516 var dot = n.lastIndexOf('.');
20517 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20518 });
20519 var kept = codeEntries.length;
20520 if (kept === 0) {
20521 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20522 if (btn) btn.disabled = false; return;
20523 }
20524
20525 function finish(tmpPath, sizes) {
20526 pathInput.value = tmpPath;
20527 scrollInputToEnd(pathInput);
20528 if (sizes) {
20529 window._lastUploadSizes = sizes;
20530 var sizeText = document.getElementById('project-size-text');
20531 var sizeBtn = document.getElementById('project-size-btn');
20532 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20533 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20534 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20535 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20536 }
20537 updateReportTitleFromPath();
20538 autoSetOutputDir(tmpPath);
20539 fetchProjectHistory(tmpPath);
20540 loadPreview();
20541 suggestCoverageFile(tmpPath);
20542 updateReview();
20543 if (btn) btn.disabled = false;
20544 }
20545
20546 if (typeof CompressionStream === 'undefined') {
20547 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20548 if (btn) btn.disabled = false; return;
20549 }
20550
20551 try {
20552 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20553 var BLOCK = 512;
20554 var cs = new CompressionStream('gzip');
20555 var wtr = cs.writable.getWriter();
20556 var chunks = [];
20557 var rdr = cs.readable.getReader();
20558 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20559
20560 function buildHdr(fp, sz) {
20561 var hdr = new Uint8Array(BLOCK);
20562 var enc = new TextEncoder();
20563 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]; }
20564 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20565 var nm = fp, pfx = '';
20566 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); } }
20567 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20568 for (var i = 148; i < 156; i++) hdr[i] = 32;
20569 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);
20570 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20571 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20572 return hdr;
20573 }
20574
20575 for (var i = 0; i < codeEntries.length; i++) {
20576 var ce = codeEntries[i];
20577 var buf = await ce.file.arrayBuffer();
20578 var data = new Uint8Array(buf);
20579 await wtr.write(buildHdr(ce.path, data.length));
20580 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20581 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20582 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20583 }
20584 await wtr.write(new Uint8Array(BLOCK * 2));
20585 await wtr.close();
20586 await collecting;
20587
20588 var blob = new Blob(chunks, { type: 'application/gzip' });
20589 var sizeMB = (blob.size / 1048576).toFixed(1);
20590 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20591 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20592 var d = await resp.json();
20593 if (d && d.tmp_path) {
20594 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20595 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20596 } catch (err) {
20597 showBannerToast('Upload failed: ' + String(err), true);
20598 if (btn) btn.disabled = false;
20599 }
20600 }).catch(function(err) {
20601 showBannerToast('Could not read folder: ' + String(err), true);
20602 if (btn) btn.disabled = false;
20603 });
20604 });
20605 }
20606 setupPathDropZone();
20607 if (browseCoverage) {
20608 browseCoverage.addEventListener("click", function () {
20609 pickDirectory(coverageInput || pathInput, "coverage");
20610 });
20611 }
20612
20613 function setCovStatus(state, opts) {
20614 if (!covScanStatus) return;
20615 opts = opts || {};
20616 covScanStatus.className = "cov-scan-status cov-scan-" + state;
20617 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
20618 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>';
20619 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>';
20620 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>';
20621 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>';
20622 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
20623 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
20624 if (state === "scanning") {
20625 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
20626 } else if (state === "found") {
20627 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20628 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
20629 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
20630 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
20631 } else if (state === "hint") {
20632 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20633 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
20634 html += '<div class="cov-scan-sub">Generate a report with your test framework\'s coverage tool, then browse to the output file. Supported: LCOV .info · Cobertura XML · JaCoCo XML · coverage.py JSON · Istanbul JSON</div>';
20635 } else if (state === "none") {
20636 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
20637 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
20638 }
20639 html += '</div></div>';
20640 covScanStatus.innerHTML = html;
20641 if (state === "found") {
20642 var useBtn = covScanStatus.querySelector(".cov-scan-use");
20643 if (useBtn) useBtn.addEventListener("click", function () {
20644 if (coverageInput) coverageInput.value = "";
20645 covAutoFilled = false;
20646 setCovStatus("idle");
20647 });
20648 }
20649 }
20650
20651 function suggestCoverageFile(projectPath) {
20652 if (!coverageInput || !covScanStatus) return;
20653 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20654 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
20655 clearTimeout(coverageSuggestTimer);
20656 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
20657 setCovStatus("scanning");
20658 coverageSuggestTimer = setTimeout(function () {
20659 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
20660 .then(function (r) { return r.json(); })
20661 .then(function (d) {
20662 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20663 if (!d) { setCovStatus("none"); return; }
20664 if (d.found) {
20665 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
20666 setCovStatus("found", { found: d.found, tool: d.tool });
20667 } else if (d.tool && d.hint) {
20668 setCovStatus("hint", { tool: d.tool, hint: d.hint });
20669 } else {
20670 setCovStatus("none");
20671 }
20672 })
20673 .catch(function () { setCovStatus("idle"); });
20674 }, 600);
20675 }
20676
20677 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
20678
20679 if (coverageInput) coverageInput.addEventListener("input", function () {
20680 covAutoFilled = false;
20681 if (!this.value.trim()) setCovStatus("idle");
20682 });
20683
20684 // ── Language pill overflow: collapse to "+N more" chip ─────────────
20685 function collapseLanguagePills() {
20686 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
20687 rows.forEach(function(row) {
20688 // Remove any previous overflow chip
20689 var prev = row.querySelector('.lang-overflow-chip');
20690 if (prev) prev.remove();
20691 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
20692 pills.forEach(function(p) { p.style.display = ''; });
20693 if (!pills.length) return;
20694
20695 // Measure after restoring all pills
20696 var containerRight = row.getBoundingClientRect().right;
20697 var hidden = [];
20698 for (var i = pills.length - 1; i >= 1; i--) {
20699 var rect = pills[i].getBoundingClientRect();
20700 if (rect.right > containerRight + 2) {
20701 hidden.unshift(pills[i]);
20702 pills[i].style.display = 'none';
20703 } else {
20704 break;
20705 }
20706 }
20707
20708 if (hidden.length) {
20709 var chip = document.createElement('button');
20710 chip.type = 'button';
20711 chip.className = 'language-pill lang-overflow-chip';
20712 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
20713 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
20714 row.appendChild(chip);
20715 }
20716 });
20717 }
20718
20719 // Run after preview loads (preview panel populates language pills)
20720 var _origLoadPreviewCb = window.__previewLoaded;
20721 document.addEventListener('previewLoaded', collapseLanguagePills);
20722 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
20723 setTimeout(collapseLanguagePills, 400);
20724
20725 // ── Project history & output dir auto-set ──────────────────────────
20726 var wsOutputRoot = document.getElementById("ws-output-root");
20727 var wsScanCount = document.getElementById("ws-scan-count");
20728 var wsLastScan = document.getElementById("ws-last-scan");
20729 var historyBadge = document.getElementById("path-history-badge");
20730 var historyTimer = null;
20731
20732 var wsOutputLink = document.getElementById("ws-output-link");
20733 function syncStripOutputRoot() {
20734 var val = outputDirInput ? outputDirInput.value : "";
20735 var display = val || "project/sloc";
20736 if (wsOutputRoot) wsOutputRoot.textContent = display;
20737 if (wsOutputLink) wsOutputLink.dataset.folder = val;
20738 }
20739
20740 function scrollInputToEnd(input) {
20741 if (!input) return;
20742 // Defer so the DOM has the new value before we measure scroll width.
20743 requestAnimationFrame(function () {
20744 input.scrollLeft = input.scrollWidth;
20745 input.selectionStart = input.selectionEnd = input.value.length;
20746 });
20747 }
20748
20749 function autoSetOutputDir(projectPath) {
20750 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
20751 if (GIT_MODE && GIT_OUTPUT_DIR) {
20752 outputDirInput.value = GIT_OUTPUT_DIR;
20753 scrollInputToEnd(outputDirInput);
20754 syncStripOutputRoot();
20755 updateReview();
20756 return;
20757 }
20758 if (!projectPath || !projectPath.trim()) return;
20759 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
20760 outputDirInput.value = cleaned + "/sloc";
20761 scrollInputToEnd(outputDirInput);
20762 syncStripOutputRoot();
20763 updateReview();
20764 }
20765
20766 var wsBranch = document.getElementById("ws-branch");
20767
20768 function fetchProjectHistory(projectPath) {
20769 if (!projectPath || !projectPath.trim()) {
20770 if (wsScanCount) wsScanCount.textContent = "\u2014";
20771 if (wsLastScan) wsLastScan.textContent = "\u2014";
20772 if (wsBranch) wsBranch.textContent = "\u2014";
20773 if (historyBadge) historyBadge.style.display = "none";
20774 return;
20775 }
20776 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
20777 .then(function (r) { return r.ok ? r.json() : null; })
20778 .then(function (data) {
20779 if (!data) return;
20780 var countStr = data.scan_count > 0
20781 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
20782 : "never";
20783 var tsStr = data.last_scan_timestamp
20784 ? data.last_scan_timestamp.replace(" UTC","")
20785 : "\u2014";
20786 if (wsScanCount) wsScanCount.textContent = countStr;
20787 if (wsLastScan) wsLastScan.textContent = tsStr;
20788 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
20789 if (data.scan_count > 0) {
20790 if (historyBadge) {
20791 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
20792 historyBadge.textContent = data.scan_count + " previous scan" +
20793 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
20794 "Last: " + (data.last_scan_timestamp || "\u2014") +
20795 " \u2014 " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?(v/1e3).toFixed(1).replace(/\.0$/,'')+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
20796 historyBadge.className = "path-history-badge found";
20797 historyBadge.style.display = "";
20798 }
20799 } else {
20800 if (historyBadge) historyBadge.style.display = "none";
20801 }
20802 })
20803 .catch(function () {});
20804 }
20805
20806 function onPathChange() {
20807 var val = pathInput ? pathInput.value : "";
20808 // Discard stale upload sizes when the user edits the path manually.
20809 window._lastUploadSizes = null;
20810 updateReportTitleFromPath();
20811 autoSetOutputDir(val);
20812 updateSidebarSummary();
20813 clearTimeout(historyTimer);
20814 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
20815 if (previewTimer) clearTimeout(previewTimer);
20816 previewTimer = setTimeout(loadPreview, 280);
20817 suggestCoverageFile(val);
20818 }
20819
20820 if (pathInput) {
20821 pathInput.addEventListener("input", onPathChange);
20822 }
20823
20824 if (outputDirInput) {
20825 outputDirInput.addEventListener("input", function () {
20826 outputDirInput.dataset.userEdited = "1";
20827 syncStripOutputRoot();
20828 updateReview();
20829 });
20830 }
20831
20832 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
20833 if (!node) return;
20834 node.addEventListener("input", function () {
20835 updateReview();
20836 if (previewTimer) clearTimeout(previewTimer);
20837 previewTimer = setTimeout(loadPreview, 280);
20838 });
20839 });
20840
20841 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
20842 var node = document.getElementById(id);
20843 if (node) node.addEventListener("change", updateReview);
20844 });
20845
20846 if (reportTitleInput) {
20847 reportTitleInput.addEventListener("input", function () {
20848 reportTitleTouched = reportTitleInput.value.trim().length > 0;
20849 updateReportTitleFromPath();
20850 updateReview();
20851 });
20852 }
20853
20854 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
20855 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
20856 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
20857 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
20858
20859 if (coverageInput) {
20860 coverageInput.addEventListener("input", function () {
20861 if (coverageInput.value.trim()) setCovStatus("idle");
20862 });
20863 }
20864
20865 if (form && loading && submitButton) {
20866 form.addEventListener("submit", function (e) {
20867 e.preventDefault();
20868 submitButton.disabled = true;
20869 submitButton.textContent = "Scanning...";
20870 startAsyncAnalysis(new FormData(form));
20871 });
20872 }
20873
20874 function openPath(folder) {
20875 if (!folder) return;
20876 fetch('/open-path?path=' + encodeURIComponent(folder))
20877 .then(function (r) { return r.json(); })
20878 .then(function (d) {
20879 if (d && d.server_mode_disabled)
20880 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20881 })
20882 .catch(function () {});
20883 }
20884
20885 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
20886 btn.addEventListener('click', function () {
20887 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
20888 });
20889 });
20890
20891 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
20892 if (wsOutputLink) {
20893 wsOutputLink.addEventListener('click', function () {
20894 openPath(wsOutputLink.dataset.folder || '');
20895 });
20896 }
20897
20898 loadSavedTheme();
20899 updateMixedPolicyUI();
20900 updatePythonDocstringUI();
20901 applyScanPreset();
20902 updatePresetDescriptions();
20903 applyArtifactPreset();
20904 updateReview();
20905 updateScrollProgress(); // initialise bar to 0% (step 1)
20906 window.addEventListener("scroll", updateScrollProgress, { passive: true });
20907 onPathChange(); // seed output dir, history badge, and preview from initial path
20908 updateStepNav(1);
20909
20910 // Restore step from URL hash on initial load (e.g., back-forward cache)
20911 (function() {
20912 var hashMatch = location.hash.match(/^#step([1-4])$/);
20913 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
20914 })();
20915
20916 (function randomizeWatermarks() {
20917 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
20918 if (!wms.length) return;
20919 var placed = [];
20920 function tooClose(top, left) {
20921 for (var i = 0; i < placed.length; i++) {
20922 var dt = Math.abs(placed[i][0] - top);
20923 var dl = Math.abs(placed[i][1] - left);
20924 if (dt < 16 && dl < 12) return true;
20925 }
20926 return false;
20927 }
20928 function pick(leftBand) {
20929 for (var attempt = 0; attempt < 50; attempt++) {
20930 var top = Math.random() * 88 + 2;
20931 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20932 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20933 }
20934 var top = Math.random() * 88 + 2;
20935 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20936 placed.push([top, left]);
20937 return [top, left];
20938 }
20939 var half = Math.floor(wms.length / 2);
20940 wms.forEach(function (img, i) {
20941 var pos = pick(i < half);
20942 var size = Math.floor(Math.random() * 80 + 110);
20943 var rot = (Math.random() * 360).toFixed(1);
20944 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
20945 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;
20946 });
20947 })();
20948
20949 (function spawnCodeParticles() {
20950 var container = document.getElementById('code-particles');
20951 if (!container) return;
20952 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'];
20953 for (var i = 0; i < 38; i++) {
20954 (function(idx) {
20955 var el = document.createElement('span');
20956 el.className = 'code-particle';
20957 el.textContent = snippets[idx % snippets.length];
20958 var left = Math.random() * 94 + 2;
20959 var top = Math.random() * 88 + 6;
20960 var dur = (Math.random() * 10 + 9).toFixed(1);
20961 var delay = (Math.random() * 18).toFixed(1);
20962 var rot = (Math.random() * 26 - 13).toFixed(1);
20963 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20964 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';
20965 container.appendChild(el);
20966 })(i);
20967 }
20968 })();
20969 })();
20970 </script>
20971 <script nonce="{{ csp_nonce }}">
20972 (function () {
20973 var raw = {{ prefill_json|safe }};
20974 if (!raw || typeof raw !== 'object' || !raw.path) return;
20975 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
20976 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
20977 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
20978 setVal('path', raw.path || '');
20979 setVal('include_globs', raw.include_globs || '');
20980 setVal('exclude_globs', raw.exclude_globs || '');
20981 setVal('output_dir', raw.output_dir || '');
20982 setVal('report_title', raw.report_title || '');
20983 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
20984 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
20985 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
20986 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
20987 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
20988 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
20989 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
20990 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
20991 setChecked('generate_html', raw.generate_html !== false);
20992 setChecked('generate_pdf', !!raw.generate_pdf);
20993 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
20994 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
20995 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
20996 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
20997 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
20998 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
20999 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21000 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21001 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21002 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21003 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21004 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21005 // Trigger dynamic UI updates after pre-fill.
21006 setTimeout(function () {
21007 var pathEl = document.getElementById('path');
21008 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21009 var policyEl = document.getElementById('mixed_line_policy');
21010 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21011 }, 80);
21012 })();
21013 </script>
21014 <script nonce="{{ csp_nonce }}">
21015 (function(){
21016 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'}];
21017 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);});}
21018 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21019 function init(){
21020 var btn=document.getElementById('settings-btn');if(!btn)return;
21021 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21022 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>';
21023 document.body.appendChild(m);
21024 var g=document.getElementById('scheme-grid');
21025 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);});
21026 var cl=document.getElementById('settings-close');
21027 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);
21028 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');});
21029 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21030 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21031 }
21032 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21033 }());
21034 </script>
21035 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21036 <div class="wb-ftip-arrow"></div>
21037 <span id="wb-ftip-text"></span>
21038 </div>
21039 <script nonce="{{ csp_nonce }}">(function(){
21040 var tip=document.getElementById('wb-ftip');
21041 var txt=document.getElementById('wb-ftip-text');
21042 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21043 if(!tip||!txt)return;
21044 function pos(el){
21045 var r=el.getBoundingClientRect();
21046 tip.style.display='block';
21047 var tw=tip.offsetWidth;
21048 var lx=r.left+r.width/2-tw/2;
21049 if(lx<8)lx=8;
21050 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21051 tip.style.left=lx+'px';
21052 tip.style.top=(r.bottom+8)+'px';
21053 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';}
21054 }
21055 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21056 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21057 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21058 });
21059 window.addEventListener('blur',function(){tip.style.display='none';});
21060 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21061 })();
21062 (function(){
21063 function fixArtifactHintSpacing(){
21064 var grid=document.querySelector('.artifact-grid');
21065 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21066 }
21067 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21068 }());
21069 (function(){
21070 var dot=document.getElementById('status-dot');
21071 var pingEl=document.getElementById('server-ping-ms');
21072 var tipEl=document.getElementById('server-tip-ping');
21073 var fm=document.getElementById('footer-mode');
21074 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)';}}
21075 function doPing(){
21076 var t0=performance.now();
21077 fetch('/healthz',{cache:'no-store'})
21078 .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);})
21079 .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)';}});
21080 }
21081 doPing();
21082 setInterval(doPing,5000);
21083 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
21084 })();
21085 </script>
21086 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21087 <footer class="site-footer">
21088 local code analysis - metrics, history and reports
21089 · <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>
21090 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21091 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21092 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21093 · <a href="/api-docs" rel="noopener">REST API</a>
21094 </footer>
21095</body>
21096</html>
21097"##,
21098 ext = "html"
21099)]
21100struct IndexTemplate {
21101 version: &'static str,
21102 prefill_json: String,
21103 csp_nonce: String,
21104 git_repo: String,
21105 git_ref: String,
21106 git_label_json: String,
21107 git_output_dir_json: String,
21108 server_mode: bool,
21109}
21110
21111#[derive(Template)]
21114#[template(
21115 source = r##"
21116<!doctype html>
21117<html lang="en">
21118<head>
21119 <meta charset="utf-8">
21120 <meta name="viewport" content="width=device-width, initial-scale=1">
21121 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21122 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21123 <script type="application/ld+json">
21124 {
21125 "@context": "https://schema.org",
21126 "@type": "SoftwareApplication",
21127 "name": "oxide-sloc",
21128 "applicationCategory": "DeveloperApplication",
21129 "operatingSystem": "Windows, Linux",
21130 "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.",
21131 "softwareVersion": "{{ version }}",
21132 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21133 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21134 "url": "https://github.com/oxide-sloc/oxide-sloc",
21135 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21136 "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",
21137 "programmingLanguage": "Rust",
21138 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21139 }
21140 </script>
21141 <style nonce="{{ csp_nonce }}">
21142 :root {
21143 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21144 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21145 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21146 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21147 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21148 }
21149 body.dark-theme {
21150 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21151 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21152 }
21153 *{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;}
21154 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21155 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21156 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21157 .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;}
21158 @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));}}
21159 .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);}
21160 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21161 .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));}
21162 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21163 .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;}
21164 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21165 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21166 @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; } }
21167 .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;}
21168 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21169 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21170 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21171 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21172 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21173 .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;}
21174 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21175 .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);}
21176 .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;}
21177 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21178 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21179 .settings-modal-body{padding:14px 16px 16px;}
21180 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21181 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21182 .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;}
21183 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21184 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21185 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21186 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21187 .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;}
21188 .tz-select:focus{border-color:var(--oxide);}
21189 .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;}
21190 .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;}
21191 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21192 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21193 .hero{text-align:center;margin:0 auto 18px;}
21194 .hero-logo-wrap{display:inline-block;cursor:default;}
21195 .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;}
21196 .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;}
21197 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21198 .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;}
21199 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%);}
21200 .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;
21201 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21202 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21203 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;}
21204 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21205 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21206 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;}
21207 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21208 .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;}
21209 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21210 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21211 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21212 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21213 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21214 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21215 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21216 .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;}
21217 .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;}
21218 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21219 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21220 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21221 .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);}
21222 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21223 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21224 .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);}
21225 .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);}
21226 .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);}
21227 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21228 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21229 .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;}
21230 body.dark-theme .action-card-cta{color:var(--oxide);}
21231 .action-card.view .action-card-cta{color:var(--accent-2);}
21232 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21233 .action-card.compare .action-card-cta{color:#7c3aed;}
21234 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21235 .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);}
21236 .action-card.git-tools .action-card-cta{color:#15803d;}
21237 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21238 .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);}
21239 .action-card.trend .action-card-cta{color:#0e7490;}
21240 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21241 .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);}
21242 .action-card.automation .action-card-cta{color:#b45309;}
21243 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21244 .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);}
21245 .action-card.test-metrics .action-card-cta{color:#be185d;}
21246 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21247 .action-card:hover .action-card-cta{gap:12px;}
21248 .action-card.card-split{flex-direction:row;align-items:stretch;}
21249 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21250 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21251 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21252 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21253 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21254 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21255 .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;}
21256 .ac-badge.active{opacity:1;}
21257 .ac-badge.github{border-color:#555;color:#555;}
21258 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21259 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21260 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21261 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21262 body.dark-theme .ac-right-row{color:var(--muted);}
21263 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21264 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21265 .divider{height:1px;background:var(--line);margin:32px 0;}
21266 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21267 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21268 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21269 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21270 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21271 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21272 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21273 body.dark-theme .info-chip-val{color:var(--oxide);}
21274 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21275 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21276 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21277 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21278 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21279 border:6px solid transparent;border-top-color:var(--text);}
21280 .info-chip:hover .info-chip-tip{display:block;}
21281 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21282 .chip-slide.fading{filter:blur(5px);opacity:0;}
21283 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21284 .site-footer a{color:var(--muted);}
21285 .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;}
21286 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21287 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21288 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21289 .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;}
21290 .lan-badge.local{background:var(--oxide-2);}
21291 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21292 .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);}
21293 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21294 .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;}
21295 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21296 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21297 .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;}
21298 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21299 .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;}
21300 .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);}
21301 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21302 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21303 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21304 .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;}
21305 @media (max-height: 1100px) {
21306 .page{padding-top:10px;}
21307 .hero{margin-bottom:10px;}
21308 .hero-logo{width:54px;height:60px;}
21309 .hero-logo-shadow{width:42px;}
21310 .hero-title{font-size:28px;}
21311 .hero-subtitle{font-size:13px;}
21312 .card-sections{gap:12px;margin-bottom:6px;}
21313 .card-section-grid-2,.card-section-grid-3{gap:10px;}
21314 .action-card{padding:8px 15px 8px;}
21315 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21316 .action-card-icon svg{width:18px;height:18px;}
21317 .action-card-title{font-size:13px;}
21318 .action-card-desc{font-size:11px;margin-bottom:6px;}
21319 .action-card-cta{font-size:11px;}
21320 .ac-right-row{font-size:11px;}
21321 .divider{margin:14px 0;}
21322 .info-strip{gap:7px;margin-bottom:8px;}
21323 .info-chip{padding:7px 10px;}
21324 .info-chip-val{font-size:13px;}
21325 .info-chip-label{font-size:9px;}
21326 .site-footer{padding:8px 24px;font-size:12px;}
21327 .lan-local-hint{margin-top:8px;}
21328 }
21329 @media (max-height: 850px) {
21330 .page{padding-top:6px;}
21331 .hero{margin-bottom:6px;}
21332 .hero-logo{width:42px;height:46px;}
21333 .hero-title{font-size:22px;}
21334 .hero-subtitle{font-size:12px;}
21335 .card-sections{gap:10px;}
21336 .action-card-desc{margin-bottom:4px;}
21337 .divider{margin:8px 0;}
21338 .info-strip{margin-bottom:6px;}
21339 .lan-local-hint{margin-top:10px;}
21340 }
21341 </style>
21342</head>
21343<body>
21344 <div class="background-watermarks" aria-hidden="true">
21345 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21346 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21347 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21348 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21349 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21350 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21351 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21352 </div>
21353 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21354 <div class="top-nav">
21355 <div class="top-nav-inner">
21356 <a class="brand" href="/">
21357 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21358 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21359 </a>
21360 <div class="nav-right">
21361 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21362 <div class="nav-dropdown">
21363 <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>
21364 <div class="nav-dropdown-menu">
21365 <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>
21366 </div>
21367 </div>
21368 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21369 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21370 <div class="nav-dropdown">
21371 <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>
21372 <div class="nav-dropdown-menu">
21373 <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>
21374 </div>
21375 </div>
21376 <div class="server-status-wrap" id="server-status-wrap">
21377 <div class="nav-pill server-online-pill" id="server-status-pill">
21378 <span class="status-dot" id="status-dot"></span>
21379 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21380 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21381 </div>
21382 <div class="server-status-tip">
21383 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21384 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21385 </div>
21386 </div>
21387 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21388 <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>
21389 </button>
21390 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21391 <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>
21392 <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>
21393 </button>
21394 </div>
21395 </div>
21396 </div>
21397
21398 <div class="page">
21399 <div class="hero">
21400 <div class="hero-logo-wrap" id="hero-logo-wrap">
21401 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21402 </div>
21403 <div class="hero-logo-shadow"></div>
21404 <div class="hero-title-wrap">
21405 <div class="hero-title-aura" aria-hidden="true"></div>
21406 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21407 </div>
21408 <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>
21409 </div>
21410
21411 <div class="card-sections">
21412
21413 <div>
21414 <div class="card-section-label">Analysis</div>
21415 <div class="card-section-grid-2">
21416 <a class="action-card scan card-split" href="/scan-setup">
21417 <div class="action-card-left">
21418 <div class="action-card-icon">
21419 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21420 </div>
21421 <div class="action-card-title">Scan Project</div>
21422 <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>
21423 <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>
21424 </div>
21425 <div class="action-card-sep"></div>
21426 <div class="action-card-right">
21427 <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>
21428 <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>
21429 <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>
21430 <div class="ac-right-stat" id="acp-scan-stat"></div>
21431 </div>
21432 </a>
21433 <a class="action-card test-metrics card-split" href="/test-metrics">
21434 <div class="action-card-left">
21435 <div class="action-card-icon">
21436 <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>
21437 </div>
21438 <div class="action-card-title">Test Metrics</div>
21439 <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>
21440 <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>
21441 </div>
21442 <div class="action-card-sep"></div>
21443 <div class="action-card-right">
21444 <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>
21445 <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>
21446 <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>
21447 <div class="ac-right-stat" id="acp-test-stat"></div>
21448 </div>
21449 </a>
21450 </div>
21451 </div>
21452
21453 <div>
21454 <div class="card-section-label">Reports & Insights</div>
21455 <div class="card-section-grid-3">
21456 <a class="action-card view" href="/view-reports">
21457 <div class="action-card-icon">
21458 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21459 </div>
21460 <div class="action-card-title">View Reports</div>
21461 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21462 <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>
21463 </a>
21464 <a class="action-card compare" href="/compare-scans">
21465 <div class="action-card-icon">
21466 <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>
21467 </div>
21468 <div class="action-card-title">Compare Scans</div>
21469 <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>
21470 <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>
21471 </a>
21472 <a class="action-card trend" href="/trend-reports">
21473 <div class="action-card-icon">
21474 <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>
21475 </div>
21476 <div class="action-card-title">Trend Report</div>
21477 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21478 <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>
21479 </a>
21480 </div>
21481 </div>
21482
21483 <div>
21484 <div class="card-section-label">Developer Tools</div>
21485 <div class="card-section-grid-2">
21486 <a class="action-card git-tools card-split" href="/git-browser">
21487 <div class="action-card-left">
21488 <div class="action-card-icon">
21489 <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>
21490 </div>
21491 <div class="action-card-title">Git Browser</div>
21492 <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>
21493 <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>
21494 </div>
21495 <div class="action-card-sep"></div>
21496 <div class="action-card-right">
21497 <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>
21498 <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>
21499 <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>
21500 </div>
21501 </a>
21502 <a class="action-card automation card-split" href="/integrations">
21503 <div class="action-card-left">
21504 <div class="action-card-icon">
21505 <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>
21506 </div>
21507 <div class="action-card-title">Integrations</div>
21508 <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>
21509 <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>
21510 </div>
21511 <div class="action-card-sep"></div>
21512 <div class="action-card-right">
21513 <div class="ac-badges-grid">
21514 <span class="ac-badge github" id="acp-gh">GitHub</span>
21515 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
21516 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
21517 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21518 </div>
21519 <div class="ac-right-stat" id="acp-int-stat"></div>
21520 </div>
21521 </a>
21522 </div>
21523 </div>
21524
21525 </div>
21526
21527 {% if server_mode %}
21528 <div class="lan-card server">
21529 <div class="lan-card-header">
21530 <span class="lan-badge">LAN server</span>
21531 Accessible on your network
21532 </div>
21533 {% if let Some(ip) = lan_ip %}
21534 <div class="lan-url-row">
21535 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21536 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21537 <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>
21538 Copy URL
21539 </button>
21540 </div>
21541 <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>
21542 {% if has_api_key %}
21543 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
21544 {% endif %}
21545 {% else %}
21546 <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>
21547 {% endif %}
21548 </div>
21549 {% endif %}
21550
21551 <div class="divider"></div>
21552
21553 <div class="info-strip">
21554 <div class="info-chip">
21555 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21556 <div class="chip-slide">
21557 <div class="info-chip-val">60</div>
21558 <div class="info-chip-label">Languages</div>
21559 </div>
21560 </div>
21561 <div class="info-chip">
21562 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21563 <div class="chip-slide">
21564 <div class="info-chip-val">100%</div>
21565 <div class="info-chip-label">Self-contained</div>
21566 </div>
21567 </div>
21568 <div class="info-chip">
21569 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21570 <div class="chip-slide">
21571 <div class="info-chip-val">HTML+PDF</div>
21572 <div class="info-chip-label">Exportable reports</div>
21573 </div>
21574 </div>
21575 <div class="info-chip">
21576 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21577 <div class="chip-slide">
21578 <div class="info-chip-val">Webhook</div>
21579 <div class="info-chip-label">3 platforms</div>
21580 </div>
21581 </div>
21582 <div class="info-chip">
21583 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21584 <div class="chip-slide">
21585 <div class="info-chip-val">IEEE</div>
21586 <div class="info-chip-label">1045-1992</div>
21587 </div>
21588 </div>
21589 </div>
21590
21591 {% if lan_ip.is_none() %}
21592 <div class="lan-local-hint">
21593 <strong>Want teammates on the same network to access this?</strong><br>
21594 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
21595 </div>
21596 {% endif %}
21597 </div>
21598
21599 <footer class="site-footer">
21600 local code analysis - metrics, history and reports
21601 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21602 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21603 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21604 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21605 · <a href="/api-docs" rel="noopener">REST API</a>
21606 </footer>
21607
21608 <script nonce="{{ csp_nonce }}">
21609 (function () {
21610 var storageKey = 'oxide-sloc-theme';
21611 var body = document.body;
21612 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21613 var toggle = document.getElementById('theme-toggle');
21614 if (toggle) toggle.addEventListener('click', function () {
21615 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21616 body.classList.toggle('dark-theme', next === 'dark');
21617 try { localStorage.setItem(storageKey, next); } catch(e) {}
21618 });
21619 var copyBtn = document.getElementById('lan-copy-btn');
21620 if (copyBtn) copyBtn.addEventListener('click', function() {
21621 var btn = this;
21622 var el = document.getElementById('lan-url-val');
21623 if (!el) return;
21624 var url = el.textContent.trim();
21625 if (navigator.clipboard) {
21626 navigator.clipboard.writeText(url).then(function() {
21627 var orig = btn.innerHTML;
21628 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!';
21629 setTimeout(function() { btn.innerHTML = orig; }, 1800);
21630 });
21631 }
21632 });
21633 (function randomizeWatermarks() {
21634 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21635 if (!wms.length) return;
21636 var placed = [];
21637 function tooClose(top, left) {
21638 for (var i = 0; i < placed.length; i++) {
21639 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21640 if (dt < 16 && dl < 12) return true;
21641 }
21642 return false;
21643 }
21644 function pick(leftBand) {
21645 for (var attempt = 0; attempt < 50; attempt++) {
21646 var top = Math.random() * 88 + 2;
21647 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21648 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21649 }
21650 var top = Math.random() * 88 + 2;
21651 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21652 placed.push([top, left]); return [top, left];
21653 }
21654 var half = Math.floor(wms.length / 2);
21655 wms.forEach(function (img, i) {
21656 var pos = pick(i < half);
21657 var size = Math.floor(Math.random() * 100 + 120);
21658 var rot = (Math.random() * 360).toFixed(1);
21659 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21660 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;
21661 });
21662 })();
21663
21664 (function spawnCodeParticles() {
21665 var container = document.getElementById('code-particles');
21666 if (!container) return;
21667 var snippets = [
21668 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21669 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21670 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21671 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21672 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21673 ];
21674 var count = 38;
21675 for (var i = 0; i < count; i++) {
21676 (function(idx) {
21677 var el = document.createElement('span');
21678 el.className = 'code-particle';
21679 var text = snippets[idx % snippets.length];
21680 el.textContent = text;
21681 var left = Math.random() * 94 + 2;
21682 var top = Math.random() * 88 + 6;
21683 var dur = (Math.random() * 10 + 9).toFixed(1);
21684 var delay = (Math.random() * 18).toFixed(1);
21685 var rot = (Math.random() * 26 - 13).toFixed(1);
21686 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21687 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
21688 + '--rot:' + rot + 'deg;--op:' + op + ';'
21689 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
21690 container.appendChild(el);
21691 })(i);
21692 }
21693 })();
21694 (function heroAnimations() {
21695 var sub = document.getElementById('hero-subtitle');
21696 if (sub) {
21697 var full = sub.textContent.trim();
21698 sub.textContent = '';
21699 sub.style.opacity = '1';
21700 var cursor = document.createElement('span');
21701 cursor.className = 'hero-cursor';
21702 sub.appendChild(cursor);
21703 var i = 0;
21704 setTimeout(function() {
21705 var iv = setInterval(function() {
21706 if (i < full.length) {
21707 sub.insertBefore(document.createTextNode(full[i]), cursor);
21708 i++;
21709 } else {
21710 clearInterval(iv);
21711 setTimeout(function() {
21712 cursor.style.transition = 'opacity 1s ease';
21713 cursor.style.opacity = '0';
21714 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
21715 }, 2400);
21716 }
21717 }, 11);
21718 }, 374);
21719 }
21720 })();
21721 (function logoBob() {
21722 var logo = document.querySelector('.hero-logo');
21723 var shadow = document.querySelector('.hero-logo-shadow');
21724 if (!logo) return;
21725 var cycleStart = null, cycleDur = 3600;
21726 var peakY = -14, peakScale = 1.07, peakRot = 0;
21727 function newCycle() {
21728 cycleDur = 3000 + Math.random() * 1840;
21729 peakY = -(9 + Math.random() * 13.8);
21730 peakScale = 1.04 + Math.random() * 0.081;
21731 peakRot = (Math.random() * 11.5 - 5.75);
21732 }
21733 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
21734 newCycle();
21735 function frame(ts) {
21736 if (cycleStart === null) cycleStart = ts;
21737 var t = (ts - cycleStart) / cycleDur;
21738 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
21739 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
21740 var y = peakY * phase;
21741 var sc = 1 + (peakScale - 1) * phase;
21742 var rot = peakRot * Math.sin(Math.PI * phase);
21743 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
21744 if (shadow) {
21745 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
21746 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
21747 }
21748 requestAnimationFrame(frame);
21749 }
21750 requestAnimationFrame(frame);
21751 })();
21752 (function mouseEffects() {
21753 var heroTitle = document.getElementById('hero-title');
21754 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
21755 function tick() {
21756 raf = null;
21757 if (heroTitle) {
21758 var r = heroTitle.getBoundingClientRect();
21759 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
21760 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
21761 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
21762 }
21763 }
21764 document.addEventListener('mousemove', function(e) {
21765 mx = e.clientX; my = e.clientY;
21766 if (!raf) raf = requestAnimationFrame(tick);
21767 });
21768 document.addEventListener('mouseleave', function() {
21769 if (heroTitle) {
21770 heroTitle.style.transition = 'transform 0.5s ease';
21771 heroTitle.style.transform = '';
21772 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
21773 }
21774 });
21775 document.querySelectorAll('.action-card').forEach(function(card) {
21776 card.addEventListener('mousemove', function(e) {
21777 var rect = card.getBoundingClientRect();
21778 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
21779 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
21780 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
21781 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
21782 });
21783 card.addEventListener('mouseleave', function() {
21784 card.style.transition = '';
21785 card.style.transform = '';
21786 });
21787 });
21788 })();
21789 (function chipSlideshow() {
21790 var slides = [
21791 [{v:'60',l:'Languages'},{v:'Rust \u00b7 Go \u00b7 Python',l:'and 57 more'},{v:'C \u00b7 Java \u00b7 TypeScript',l:'Swift \u00b7 Kotlin \u00b7 Zig'}],
21792 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
21793 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
21794 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
21795 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
21796 ];
21797 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
21798 var indices = [0,0,0,0,0];
21799 var paused = [false,false,false,false,false];
21800 chips.forEach(function(chip, i) {
21801 chip.addEventListener('mouseenter', function() { paused[i] = true; });
21802 chip.addEventListener('mouseleave', function() { paused[i] = false; });
21803 });
21804 function advance(i) {
21805 if (paused[i]) return;
21806 var chip = chips[i];
21807 var inner = chip.querySelector('.chip-slide');
21808 if (!inner) return;
21809 inner.classList.add('fading');
21810 setTimeout(function() {
21811 indices[i] = (indices[i] + 1) % slides[i].length;
21812 var s = slides[i][indices[i]];
21813 chip.querySelector('.info-chip-val').textContent = s.v;
21814 chip.querySelector('.info-chip-label').textContent = s.l;
21815 inner.classList.remove('fading');
21816 }, 720);
21817 }
21818 setInterval(function() {
21819 chips.forEach(function(chip, i) { advance(i); });
21820 }, 6000);
21821 })();
21822 (function cardLiveData() {
21823 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
21824 var el = document.getElementById('acp-scan-stat');
21825 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
21826 }).catch(function(){});
21827 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
21828 var el = document.getElementById('acp-test-stat');
21829 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
21830 }).catch(function(){});
21831 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
21832 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
21833 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
21834 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
21835 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
21836 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
21837 var stat = document.getElementById('acp-int-stat');
21838 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
21839 }).catch(function(){});
21840 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
21841 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
21842 }).catch(function(){});
21843 })();
21844 })();
21845 </script>
21846 <script nonce="{{ csp_nonce }}">
21847 (function(){
21848 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'}];
21849 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);});}
21850 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21851 function init(){
21852 var btn=document.getElementById('settings-btn');if(!btn)return;
21853 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21854 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>';
21855 document.body.appendChild(m);
21856 var g=document.getElementById('scheme-grid');
21857 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);});
21858 var cl=document.getElementById('settings-close');
21859 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);
21860 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');});
21861 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21862 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21863 }
21864 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21865 }());
21866 </script>
21867 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
21868</body>
21869</html>
21870"##,
21871 ext = "html"
21872)]
21873struct SplashTemplate {
21874 csp_nonce: String,
21875 server_mode: bool,
21876 lan_ip: Option<String>,
21877 port: u16,
21878 version: &'static str,
21879 has_api_key: bool,
21880}
21881
21882#[derive(Template)]
21885#[template(
21886 source = r##"
21887<!doctype html>
21888<html lang="en">
21889<head>
21890 <meta charset="utf-8">
21891 <meta name="viewport" content="width=device-width, initial-scale=1">
21892 <title>OxideSLOC — Start a Scan</title>
21893 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21894 <style nonce="{{ csp_nonce }}">
21895 :root {
21896 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
21897 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21898 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21899 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21900 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21901 }
21902 body.dark-theme {
21903 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21904 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21905 }
21906 *{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;}
21907 .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);}
21908 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21909 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
21910 .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));}
21911 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21912 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21913 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
21914 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21915 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21916 @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; } }
21917 .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;}
21918 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21919 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21920 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21921 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21922 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21923 .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;}
21924 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21925 .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);}
21926 .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;}
21927 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21928 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21929 .settings-modal-body{padding:14px 16px 16px;}
21930 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21931 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21932 .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;}
21933 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21934 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21935 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21936 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21937 .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;}
21938 .tz-select:focus{border-color:var(--oxide);}
21939 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21940 .page-header{text-align:center;margin-bottom:16px;}
21941 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
21942 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
21943 /* Cards */
21944 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
21945 .option-card-wrap{position:relative;}
21946 .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;}
21947 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
21948 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21949 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
21950 .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;}
21951 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
21952 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
21953 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
21954 .card-top-row{display:flex;align-items:center;gap:20px;}
21955 /* Two-column layout inside each card */
21956 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
21957 .card-left{display:flex;align-items:flex-start;min-width:0;}
21958 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
21959 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
21960 .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);}
21961 .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);}
21962 .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);}
21963 .card-text{min-width:0;}
21964 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
21965 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
21966 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
21967 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
21968 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
21969 /* Right CTA column */
21970 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
21971 .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;}
21972 /* Re-scan count badge */
21973 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
21974 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
21975 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
21976 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
21977 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
21978 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
21979 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
21980 body.dark-theme .btn-secondary{color:var(--oxide);}
21981 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
21982 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
21983 /* File input overlay — must be full-width so it aligns with other card-right buttons */
21984 .file-input-wrap{position:relative;width:100%;}
21985 .file-input-wrap .btn{width:100%;}
21986 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
21987 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21988 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21989 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21990 .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;}
21991 @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));}}
21992 /* Recent list (card 3 — full-width section below header) */
21993 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
21994 .recent-list{display:flex;flex-direction:column;gap:8px;}
21995 .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;}
21996 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
21997 .recent-item-info{flex:1;min-width:0;}
21998 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
21999 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22000 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22001 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22002 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22003 .site-footer a{color:var(--muted);}
22004 @media(max-width:680px){
22005 .card-body{grid-template-columns:1fr;}
22006 .card-right{flex-direction:row;flex-wrap:wrap;}
22007 .btn{flex:1;}
22008 }
22009 .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;}
22010 .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;}
22011 .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;}
22012 </style>
22013</head>
22014<body>
22015 <div class="background-watermarks" aria-hidden="true">
22016 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22017 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22018 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22019 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22020 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22021 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22022 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22023 </div>
22024 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22025 <div class="top-nav">
22026 <div class="top-nav-inner">
22027 <a class="brand" href="/">
22028 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22029 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22030 </a>
22031 <div class="nav-right">
22032 <a class="nav-pill" href="/">Home</a>
22033 <div class="nav-dropdown">
22034 <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>
22035 <div class="nav-dropdown-menu">
22036 <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>
22037 </div>
22038 </div>
22039 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22040 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22041 <div class="nav-dropdown">
22042 <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>
22043 <div class="nav-dropdown-menu">
22044 <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>
22045 </div>
22046 </div>
22047 <div class="server-status-wrap" id="server-status-wrap">
22048 <div class="nav-pill server-online-pill" id="server-status-pill">
22049 <span class="status-dot" id="status-dot"></span>
22050 <span id="server-status-label">Server</span>
22051 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22052 </div>
22053 <div class="server-status-tip">
22054 OxideSLOC is running — accessible on your network.
22055 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22056 </div>
22057 </div>
22058 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22059 <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>
22060 </button>
22061 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22062 <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>
22063 <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>
22064 </button>
22065 </div>
22066 </div>
22067 </div>
22068
22069 <div class="page">
22070 <div class="page-header">
22071 <h1>How would you like to scan?</h1>
22072 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22073 </div>
22074
22075 <div class="option-grid">
22076
22077 <!-- Option 1: New scan -->
22078 <div class="option-card-wrap">
22079 <div class="option-card">
22080 <div class="option-icon new-scan">
22081 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22082 </div>
22083 <div class="card-body">
22084 <div class="card-left">
22085 <div class="card-text">
22086 <div class="option-title">Start a new scan</div>
22087 <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>
22088 <ul class="feature-list">
22089 <li>Live project scope preview before you run</li>
22090 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22091 <li>HTML, PDF, and JSON output — your choice</li>
22092 </ul>
22093 </div>
22094 </div>
22095 <div class="card-right">
22096 <a class="btn btn-primary" href="/scan">
22097 Configure & scan
22098 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22099 </a>
22100 <p class="card-tip">Full 4-step setup · all options</p>
22101 </div>
22102 </div>
22103 </div>
22104 </div>
22105
22106 <!-- Option 2: Load from config file -->
22107 <div class="option-card-wrap">
22108 <div class="option-card">
22109 <div class="option-icon load-config">
22110 <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>
22111 </div>
22112 <div class="card-body">
22113 <div class="card-left">
22114 <div class="card-text">
22115 <div class="option-title">Load a saved config</div>
22116 <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>
22117 <ul class="feature-list">
22118 <li>All 15 settings restored from the file</li>
22119 <li>Fully editable — change path or output dir</li>
22120 <li>Works with any scan-config.json</li>
22121 </ul>
22122 </div>
22123 </div>
22124 <div class="card-right">
22125 <div class="file-input-wrap">
22126 <button class="btn btn-secondary" id="load-config-btn" type="button">
22127 <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>
22128 Choose config file
22129 </button>
22130 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22131 </div>
22132 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22133 </div>
22134 </div>
22135 </div>
22136 </div>
22137
22138 <!-- Option 3: Re-scan recent project -->
22139 <div class="option-card-wrap">
22140 <div class="option-card" id="recent-card">
22141 <div class="card-top-row">
22142 <div class="option-icon rescan">
22143 <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>
22144 </div>
22145 <div class="card-body">
22146 <div class="card-left">
22147 <div class="card-text">
22148 <div class="option-title">Re-scan a recent project</div>
22149 <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>
22150 <ul class="feature-list">
22151 <li>All 15+ settings restored from the saved config</li>
22152 <li>Path and output dir are editable before running</li>
22153 <li>Only scans with a saved config appear here</li>
22154 </ul>
22155 </div>
22156 </div>
22157 <div class="card-right">
22158 <div class="rescan-count-box">
22159 <div class="rescan-count-num" id="rescan-count-num">—</div>
22160 <div class="rescan-count-label">saved configs</div>
22161 </div>
22162 <a class="btn btn-secondary" href="/view-reports">
22163 <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>
22164 View all runs
22165 </a>
22166 <p class="card-tip">Opens run history</p>
22167 </div>
22168 </div>
22169 </div>
22170 <div class="section-divider"></div>
22171 <div class="recent-list" id="recent-list">
22172 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22173 </div>
22174 </div>
22175 </div>
22176
22177 </div>
22178 </div>
22179
22180 <footer class="site-footer">
22181 local code analysis - metrics, history and reports
22182 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22183 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22184 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22185 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22186 · <a href="/api-docs" rel="noopener">REST API</a>
22187 </footer>
22188
22189 <script nonce="{{ csp_nonce }}">
22190 (function () {
22191 var storageKey = 'oxide-sloc-theme';
22192 var body = document.body;
22193 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22194 var toggle = document.getElementById('theme-toggle');
22195 if (toggle) toggle.addEventListener('click', function () {
22196 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22197 body.classList.toggle('dark-theme', next === 'dark');
22198 try { localStorage.setItem(storageKey, next); } catch(e) {}
22199 });
22200
22201 (function randomizeWatermarks() {
22202 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22203 if (!wms.length) return;
22204 var placed = [];
22205 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; }
22206 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]; }
22207 var half = Math.floor(wms.length / 2);
22208 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; });
22209 })();
22210 (function spawnCodeParticles() {
22211 var container = document.getElementById('code-particles');
22212 if (!container) return;
22213 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'];
22214 var count = 38;
22215 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); }
22216 })();
22217 // Recent scans data injected from server
22218 var recentScans = {{ recent_scans_json|safe }};
22219
22220 function configToParams(cfg) {
22221 var p = new URLSearchParams();
22222 p.set('prefilled', '1');
22223 if (cfg.path) p.set('path', cfg.path);
22224 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22225 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22226 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22227 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22228 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22229 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22230 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22231 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22232 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22233 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22234 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22235 if (cfg.report_title) p.set('report_title', cfg.report_title);
22236 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22237 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22238 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22239 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22240 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22241 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22242 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22243 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22244 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22245 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22246 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22247 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22248 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22249 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22250 return p;
22251 }
22252
22253 // Build recent scan list (capped at 3 visible entries)
22254 var list = document.getElementById('recent-list');
22255 var noNote = document.getElementById('no-recent-note');
22256 var hasAny = false;
22257 var MAX_RECENT = 3;
22258 if (Array.isArray(recentScans)) {
22259 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22260 var shown = 0;
22261 validEntries.forEach(function (entry) {
22262 if (shown >= MAX_RECENT) return;
22263 shown++;
22264 hasAny = true;
22265 var item = document.createElement('div');
22266 item.className = 'recent-item';
22267 item.title = 'Restore all settings and open wizard';
22268 item.innerHTML =
22269 '<div class="recent-item-info">' +
22270 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22271 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
22272 '</div>' +
22273 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22274 item.addEventListener('click', function () {
22275 var params = configToParams(entry.config);
22276 window.location.href = '/scan?' + params.toString();
22277 });
22278 list.appendChild(item);
22279 });
22280 if (validEntries.length > MAX_RECENT) {
22281 var moreEl = document.createElement('div');
22282 moreEl.className = 'recent-more-link';
22283 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
22284 list.appendChild(moreEl);
22285 }
22286 }
22287 if (hasAny && noNote) noNote.style.display = 'none';
22288 // Update count badge
22289 var countEl = document.getElementById('rescan-count-num');
22290 if (countEl) {
22291 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22292 countEl.textContent = total > 0 ? total : '0';
22293 }
22294
22295 // Config file loader
22296 var fileInput = document.getElementById('config-file-input');
22297 var fileName = document.getElementById('config-file-name');
22298 var loadBtn = document.getElementById('load-config-btn');
22299 // Wire the visible button to open the hidden file picker.
22300 if (loadBtn && fileInput) {
22301 loadBtn.addEventListener('click', function () { fileInput.click(); });
22302 }
22303 if (fileInput) {
22304 fileInput.addEventListener('change', function () {
22305 var file = fileInput.files && fileInput.files[0];
22306 if (!file) return;
22307 if (fileName) fileName.textContent = '\u2713 ' + file.name;
22308 var reader = new FileReader();
22309 reader.onload = function (e) {
22310 try {
22311 var cfg = JSON.parse(e.target.result);
22312 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22313 var params = configToParams(cfg);
22314 window.location.href = '/scan?' + params.toString();
22315 } catch (err) {
22316 alert('Could not parse config file: ' + err.message);
22317 }
22318 };
22319 reader.readAsText(file);
22320 });
22321 }
22322
22323 function escHtml(s) {
22324 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
22325 }
22326 })();
22327 </script>
22328 <script nonce="{{ csp_nonce }}">
22329 (function(){
22330 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'}];
22331 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);});}
22332 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22333 function init(){
22334 var btn=document.getElementById('settings-btn');if(!btn)return;
22335 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22336 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>';
22337 document.body.appendChild(m);
22338 var g=document.getElementById('scheme-grid');
22339 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);});
22340 var cl=document.getElementById('settings-close');
22341 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);
22342 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');});
22343 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22344 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22345 }
22346 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22347 }());
22348 </script>
22349 <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]';
22350 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;}
22351 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>
22352</body>
22353</html>
22354"##,
22355 ext = "html"
22356)]
22357struct ScanSetupTemplate {
22358 version: &'static str,
22359 recent_scans_json: String,
22360 csp_nonce: String,
22361}
22362
22363#[derive(Template)]
22364#[template(
22365 source = r##"
22366<!doctype html>
22367<html lang="en">
22368<head>
22369 <meta charset="utf-8">
22370 <meta name="viewport" content="width=device-width, initial-scale=1">
22371 <title>OxideSLOC | {{ report_title }} | Report</title>
22372 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22373 <style nonce="{{ csp_nonce }}">
22374 :root {
22375 --radius: 18px;
22376 --bg: #f5efe8;
22377 --surface: rgba(255,255,255,0.82);
22378 --surface-2: #fbf7f2;
22379 --surface-3: #efe6dc;
22380 --line: #e6d0bf;
22381 --line-strong: #dcb89f;
22382 --text: #43342d;
22383 --muted: #7b675b;
22384 --muted-2: #a08777;
22385 --nav: #b85d33;
22386 --nav-2: #7a371b;
22387 --accent: #6f9bff;
22388 --accent-2: #4a78ee;
22389 --oxide: #d37a4c;
22390 --oxide-2: #b35428;
22391 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22392 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22393 --success-bg: #e8f5ed;
22394 --success-text: #1a8f47;
22395 --info-bg: #eef3ff;
22396 --info-text: #4467d8;
22397 }
22398
22399 body.dark-theme {
22400 --bg: #1b1511;
22401 --surface: #261c17;
22402 --surface-2: #2d221d;
22403 --surface-3: #372922;
22404 --line: #524238;
22405 --line-strong: #6c5649;
22406 --text: #f5ece6;
22407 --muted: #c7b7aa;
22408 --muted-2: #aa9485;
22409 --nav: #b85d33;
22410 --nav-2: #7a371b;
22411 --accent: #6f9bff;
22412 --accent-2: #4a78ee;
22413 --oxide: #d37a4c;
22414 --oxide-2: #b35428;
22415 --shadow: 0 18px 42px rgba(0,0,0,0.28);
22416 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22417 --success-bg: #163927;
22418 --success-text: #8fe2a8;
22419 --info-bg: #1c2847;
22420 --info-text: #a9c1ff;
22421 }
22422
22423 * { box-sizing: border-box; }
22424 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); }
22425 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22426 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22427 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22428 .top-nav, .page { position: relative; z-index: 2; }
22429 .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); }
22430 .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; }
22431 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22432 .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)); }
22433 .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; }
22434 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22435 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22436 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22437 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22438 .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; }
22439 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22440 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22441 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22442 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22443 @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; } }
22444 .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; }
22445 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22446 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22447 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22448 .theme-toggle .icon-sun { display:none; }
22449 body.dark-theme .theme-toggle .icon-sun { display:block; }
22450 body.dark-theme .theme-toggle .icon-moon { display:none; }
22451 .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;}
22452 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22453 .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);}
22454 .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;}
22455 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22456 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22457 .settings-modal-body{padding:14px 16px 16px;}
22458 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22459 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22460 .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;}
22461 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22462 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22463 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22464 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22465 .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;}
22466 .tz-select:focus{border-color:var(--oxide);}
22467 .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; }
22468 .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;}
22469 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22470 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22471 .hero, .panel { padding: 22px; }
22472 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22473 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22474 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22475 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22476 .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; }
22477 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22478 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22479 .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; }
22480 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22481 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22482 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22483 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22484 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22485 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22486 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
22487 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22488 .delta-card-val { font-size:16px; font-weight:800; }
22489 .delta-card-val.pos { color:#1e7e34; }
22490 .delta-card-val.neg { color:var(--neg); }
22491 .delta-card-val.mod { color:#b35428; }
22492 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22493 .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; }
22494 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22495 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22496 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22497 .compare-ts { font-size:13px; color:var(--muted); }
22498 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22499 .compare-arrow { color: var(--muted); }
22500 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22501 .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; }
22502 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22503 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22504 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22505 .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; }
22506 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22507 .run-mgmt-card .action-buttons { justify-content:center; }
22508 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22509 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22510 .button, .copy-button {
22511 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;
22512 }
22513 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22514 @keyframes spin { to { transform: rotate(360deg); } }
22515 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22516 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22517 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22518 .path-item strong { display: block; margin-bottom: 6px; }
22519 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22520 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22521 .path-subitem { flex: 1; }
22522 .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); }
22523 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); }
22524 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22525 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22526 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22527 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22528 th { color: var(--muted); font-weight: 700; }
22529 tr:last-child td { border-bottom: none; }
22530 #subm-tbl col:nth-child(1){width:15%;}
22531 #subm-tbl col:nth-child(2){width:31%;}
22532 #subm-tbl col:nth-child(3){width:9%;}
22533 #subm-tbl col:nth-child(4){width:9%;}
22534 #subm-tbl col:nth-child(5){width:9%;}
22535 #subm-tbl col:nth-child(6){width:9%;}
22536 #subm-tbl col:nth-child(7){width:9%;}
22537 #subm-tbl col:nth-child(8){width:9%;}
22538 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22539 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22540 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22541 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22542 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22543 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22544 .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; }
22545 .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; }
22546 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22547 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22548 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22549 .muted { color: var(--muted); }
22550 /* Run-ID chip row (mirrors HTML report) */
22551 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22552 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22553 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22554 .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; }
22555 .run-id-chip[data-copy] { cursor:pointer; }
22556 a.run-id-chip { text-decoration:none; cursor:pointer; }
22557 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22558 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22559 .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; }
22560 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22561 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22562 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22563 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22564 a.commit-link-value { color:inherit; text-decoration:none; }
22565 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22566 .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
22567 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22568 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22569 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22570 .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; }
22571 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22572 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22573 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22574 /* Meta chips row */
22575 .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%; }
22576 .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; }
22577 .meta-chip:last-child { border-right:none; }
22578 .meta-chip b { color:var(--text); font-weight:700; }
22579 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22580 .site-footer a{color:var(--muted);}
22581 .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; }
22582 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22583 .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; }
22584 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22585 /* Stat chips (matches HTML report) */
22586 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22587 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22588 /* Hero stat strip: uniform grid where every card is the same width and the
22589 columns line up across both rows. JS sets the column count to ceil(n/2) so
22590 the cards always occupy exactly two rows; when the count is odd the last
22591 card spans two columns to fill the trailing cell with no empty gap. */
22592 .summary-strip-hero { align-items:stretch; }
22593 .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); overflow:visible; }
22594 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22595 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22596 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22597 .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; }
22598 .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
22599 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22600 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22601 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
22602 .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; }
22603 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
22604 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
22605 .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); }
22606 .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; transform:translateY(-7px); background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
22607 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
22608 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
22609 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
22610 /* Submodule panel */
22611 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
22612 /* Metrics tables stack */
22613 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
22614 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22615 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
22616 .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)); }
22617 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
22618 /* Metrics table */
22619 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
22620 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
22621 .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; }
22622 .metrics-table thead th:not(:first-child) { text-align: right; }
22623 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
22624 .metrics-table tbody tr:last-child td { border-bottom: none; }
22625 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
22626 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
22627 .metrics-table tbody tr:hover td { background: var(--surface-2); }
22628 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
22629 .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; }
22630 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
22631 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
22632 .mt-val-pos { color: var(--pos); font-weight: 700; }
22633 .mt-val-neg { color: var(--neg); font-weight: 700; }
22634 .mt-val-zero { color: var(--muted); }
22635 .mt-val-mod { color: var(--oxide-2); }
22636 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
22637 @media (max-width: 1180px) {
22638 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
22639 .nav-project-slot, .nav-status { justify-content:flex-start; }
22640 .hero-top { flex-direction: column; }
22641 .run-mgmt-strip { flex-direction: column; }
22642 }
22643 .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;}
22644 @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));}}
22645 .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;}
22646 /* ── Result-page chart controls ─────────────────────────────────────────── */
22647 .r-chart-section{margin-bottom:24px;}
22648 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
22649 .section-pair > .panel{flex-shrink:0;}
22650 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
22651 .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;}
22652 .r-chart-select:focus{border-color:var(--accent);}
22653 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
22654 .r-chart-container svg{display:block;width:100%;height:auto;}
22655 .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;}
22656 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
22657 .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;}
22658 .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);}
22659 .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;}
22660 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
22661 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
22662 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
22663 .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;}
22664 .r-chart-modal-close:hover{opacity:.7;}
22665 body.dark-theme .r-chart-modal{background:var(--surface);}
22666 .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;}
22667 .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);}
22668 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
22669 .lang-bar-row:hover{transform:translateY(-2px);}
22670 .lang-bar-row .rchit:hover{filter:none;transform:none;}
22671 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
22672 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
22673 .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;}
22674 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
22675 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
22676 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
22677 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
22678 #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;}
22679 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
22680 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
22681 .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;}
22682 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
22683 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
22684 .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;}
22685 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
22686 .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;}
22687 .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;}
22688 body.has-report-banner .top-nav{top:27px;}
22689 body.has-report-banner{padding-bottom:27px;}
22690 </style>
22691</head>
22692<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
22693 <div class="background-watermarks" aria-hidden="true">
22694 <img src="/images/logo/logo-text.png" alt="" />
22695 <img src="/images/logo/logo-text.png" alt="" />
22696 <img src="/images/logo/logo-text.png" alt="" />
22697 <img src="/images/logo/logo-text.png" alt="" />
22698 <img src="/images/logo/logo-text.png" alt="" />
22699 <img src="/images/logo/logo-text.png" alt="" />
22700 <img src="/images/logo/logo-text.png" alt="" />
22701 <img src="/images/logo/logo-text.png" alt="" />
22702 <img src="/images/logo/logo-text.png" alt="" />
22703 <img src="/images/logo/logo-text.png" alt="" />
22704 <img src="/images/logo/logo-text.png" alt="" />
22705 <img src="/images/logo/logo-text.png" alt="" />
22706 <img src="/images/logo/logo-text.png" alt="" />
22707 <img src="/images/logo/logo-text.png" alt="" />
22708 </div>
22709 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22710 {% if let Some(banner) = report_header_footer %}
22711 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
22712 {% endif %}
22713 <div class="top-nav">
22714 <div class="top-nav-inner">
22715 <a class="brand" href="/">
22716 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22717 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22718 </a>
22719 <div class="nav-project-slot">
22720 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
22721 </div>
22722 <div class="nav-status">
22723 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
22724 <div class="nav-dropdown">
22725 <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>
22726 <div class="nav-dropdown-menu">
22727 <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>
22728 </div>
22729 </div>
22730 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
22731 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22732 <div class="nav-dropdown">
22733 <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>
22734 <div class="nav-dropdown-menu">
22735 <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>
22736 </div>
22737 </div>
22738 <div class="server-status-wrap" id="server-status-wrap">
22739 <div class="nav-pill server-online-pill" id="server-status-pill">
22740 <span class="status-dot" id="status-dot"></span>
22741 <span id="server-status-label">Server</span>
22742 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22743 </div>
22744 <div class="server-status-tip">
22745 OxideSLOC is running — accessible on your network.
22746 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22747 </div>
22748 </div>
22749 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22750 <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>
22751 </button>
22752 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
22753 <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>
22754 <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>
22755 </button>
22756 </div>
22757 </div>
22758 </div>
22759
22760 <div class="page">
22761 <section class="hero">
22762 <div class="hero-top">
22763 <div>
22764 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
22765 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
22766 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
22767 <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>
22768 </div>
22769 </div>
22770 <div class="hero-quick-actions">
22771 {% if server_mode %}
22772 <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>
22773 {% else %}
22774 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
22775 {% endif %}
22776 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
22777 {% if !server_mode %}
22778 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
22779 {% endif %}
22780 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
22781 <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>
22782 </div>
22783 </div>
22784
22785 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
22786 <div class="run-id-row">
22787 <span class="run-id-chip" data-copy="{{ run_id }}">
22788 <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>
22789 <span class="run-id-chip-value">{{ run_id }}</span>
22790 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
22791 </span>
22792 {% match git_commit_long %}
22793 {% when Some with (long_sha) %}
22794 {% match git_commit_url %}
22795 {% when Some with (commit_url) %}
22796 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
22797 <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>
22798 <span class="run-id-chip-value">{{ long_sha }}</span>
22799 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
22800 </a>
22801 {% when None %}
22802 <span class="run-id-chip" data-copy="{{ long_sha }}">
22803 <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>
22804 <span class="run-id-chip-value">{{ long_sha }}</span>
22805 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
22806 </span>
22807 {% endmatch %}
22808 {% when None %}
22809 <span class="run-id-chip muted-chip">
22810 <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>
22811 <span class="run-id-chip-value">Not detected</span>
22812 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
22813 </span>
22814 {% endmatch %}
22815 {% match git_branch %}
22816 {% when Some with (branch) %}
22817 {% match git_branch_url %}
22818 {% when Some with (branch_url) %}
22819 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
22820 <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>
22821 <span class="run-id-chip-value">{{ branch }}</span>
22822 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
22823 </a>
22824 {% when None %}
22825 <span class="run-id-chip">
22826 <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>
22827 <span class="run-id-chip-value">{{ branch }}</span>
22828 <span class="chip-tooltip">Git branch active at scan time</span>
22829 </span>
22830 {% endmatch %}
22831 {% when None %}
22832 <span class="run-id-chip muted-chip">
22833 <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>
22834 <span class="run-id-chip-value">Not detected</span>
22835 <span class="chip-tooltip">No Git branch was found for this scan</span>
22836 </span>
22837 {% endmatch %}
22838 {% match git_author %}
22839 {% when Some with (author) %}
22840 <span class="run-id-chip" data-author="{{ author }}">
22841 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
22842 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
22843 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
22844 </span>
22845 {% when None %}
22846 <span class="run-id-chip muted-chip">
22847 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
22848 <span class="run-id-chip-value">Not detected</span>
22849 <span class="chip-tooltip">No commit author was found for this scan</span>
22850 </span>
22851 {% endmatch %}
22852 </div>
22853
22854 <!-- Scan metadata row -->
22855 <div class="meta">
22856 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
22857 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
22858 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
22859 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
22860 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
22861 </div>
22862
22863 <!-- All summary stat chips in one unified strip (8 columns) -->
22864 <div class="summary-strip summary-strip-hero">
22865 <div class="stat-chip" data-raw="{{ physical_lines }}">
22866 <div class="stat-chip-label">Physical lines</div>
22867 <div class="stat-chip-val">{{ physical_lines }}</div>
22868 <div class="stat-chip-exact"></div>
22869 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
22870 </div>
22871 <div class="stat-chip" data-raw="{{ code_lines }}">
22872 <div class="stat-chip-label">Code</div>
22873 <div class="stat-chip-val">{{ code_lines }}</div>
22874 <div class="stat-chip-exact"></div>
22875 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
22876 </div>
22877 <div class="stat-chip" data-raw="{{ comment_lines }}">
22878 <div class="stat-chip-label">Comments</div>
22879 <div class="stat-chip-val">{{ comment_lines }}</div>
22880 <div class="stat-chip-exact"></div>
22881 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
22882 </div>
22883 <div class="stat-chip" data-raw="{{ blank_lines }}">
22884 <div class="stat-chip-label">Blank</div>
22885 <div class="stat-chip-val">{{ blank_lines }}</div>
22886 <div class="stat-chip-exact"></div>
22887 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
22888 </div>
22889 <div class="stat-chip" data-raw="{{ mixed_lines }}">
22890 <div class="stat-chip-label">Mixed separate</div>
22891 <div class="stat-chip-val">{{ mixed_lines }}</div>
22892 <div class="stat-chip-exact"></div>
22893 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
22894 </div>
22895 <div class="stat-chip" data-raw="{{ functions }}">
22896 <div class="stat-chip-label">Functions</div>
22897 <div class="stat-chip-val">{{ functions }}</div>
22898 <div class="stat-chip-exact"></div>
22899 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
22900 </div>
22901 <div class="stat-chip" data-raw="{{ classes }}">
22902 <div class="stat-chip-label">Classes / Types</div>
22903 <div class="stat-chip-val">{{ classes }}</div>
22904 <div class="stat-chip-exact"></div>
22905 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
22906 </div>
22907 <div class="stat-chip" data-raw="{{ variables }}">
22908 <div class="stat-chip-label">Variables</div>
22909 <div class="stat-chip-val">{{ variables }}</div>
22910 <div class="stat-chip-exact"></div>
22911 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
22912 </div>
22913 <div class="stat-chip" data-raw="{{ imports }}">
22914 <div class="stat-chip-label">Imports</div>
22915 <div class="stat-chip-val">{{ imports }}</div>
22916 <div class="stat-chip-exact"></div>
22917 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
22918 </div>
22919 <div class="stat-chip" data-raw="{{ test_count }}">
22920 <div class="stat-chip-label">Tests</div>
22921 <div class="stat-chip-val">{{ test_count }}</div>
22922 <div class="stat-chip-exact"></div>
22923 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
22924 </div>
22925 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
22926 <div class="stat-chip-label">Code density</div>
22927 <div class="stat-chip-val stat-chip-density-val">—</div>
22928 <div class="stat-chip-exact"></div>
22929 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
22930 </div>
22931 <div class="stat-chip" data-raw="{{ files_analyzed }}">
22932 <div class="stat-chip-label">Files analyzed</div>
22933 <div class="stat-chip-val">{{ files_analyzed }}</div>
22934 <div class="stat-chip-exact"></div>
22935 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
22936 </div>
22937 {% if cyclomatic_complexity > 0 %}
22938 <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 %}>
22939 <div class="stat-chip-label">Complexity score</div>
22940 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
22941 <div class="stat-chip-exact"></div>
22942 <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>
22943 </div>
22944 {% endif %}
22945 {% if let Some(ls) = lsloc %}
22946 <div class="stat-chip" data-raw="{{ ls }}">
22947 <div class="stat-chip-label">Logical SLOC</div>
22948 <div class="stat-chip-val">{{ ls }}</div>
22949 <div class="stat-chip-exact"></div>
22950 <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>
22951 </div>
22952 {% endif %}
22953 {% if uloc > 0 %}
22954 <div class="stat-chip" data-raw="{{ uloc }}">
22955 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
22956 <div class="stat-chip-val">{{ uloc }}</div>
22957 <div class="stat-chip-exact"></div>
22958 <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>
22959 </div>
22960 {% endif %}
22961 {% if uloc > 0 && dryness_pct_str != "" %}
22962 <div class="stat-chip">
22963 <div class="stat-chip-label">DRYness</div>
22964 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
22965 <div class="stat-chip-exact"></div>
22966 <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>
22967 </div>
22968 {% endif %}
22969 {% if duplicate_group_count > 0 %}
22970 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
22971 <div class="stat-chip-label">Duplicate groups</div>
22972 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
22973 <div class="stat-chip-exact"></div>
22974 <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>
22975 </div>
22976 {% endif %}
22977 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
22978 odd, so the strip always forms exactly two full rows with every column
22979 aligned and every card the same width (no oversized card, no gap). -->
22980 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
22981 <div class="stat-chip-label">Assertions</div>
22982 <div class="stat-chip-val">{{ test_assertion_count }}</div>
22983 <div class="stat-chip-exact"></div>
22984 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
22985 </div>
22986 </div>
22987
22988 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
22989 <div class="compare-banner">
22990 <div class="compare-banner-body">
22991 <div class="compare-banner-top">
22992 <div class="compare-banner-meta">
22993 <span class="compare-label">Previous scan</span>
22994 <span class="compare-ts">{{ prev_ts }}</span>
22995 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
22996 {% if let Some(prev_code) = prev_run_code_lines %}
22997 <div class="compare-banner-stats" style="margin-top:4px;">
22998 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
22999 <span class="compare-arrow">→</span>
23000 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23001 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23002 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23003 </div>
23004 {% endif %}
23005 </div>
23006 {% if delta_lines_added.is_some() %}
23007 <div class="delta-cards-inline">
23008 <div class="delta-card-inline">
23009 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23010 <div class="delta-card-lbl">lines added</div>
23011 <div class="delta-card-tip">Code lines added since the previous scan</div>
23012 </div>
23013 <div class="delta-card-inline">
23014 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23015 <div class="delta-card-lbl">lines removed</div>
23016 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23017 </div>
23018 <div class="delta-card-inline">
23019 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23020 <div class="delta-card-lbl">unmodified lines</div>
23021 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23022 </div>
23023 <div class="delta-card-inline">
23024 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23025 <div class="delta-card-lbl">files modified</div>
23026 <div class="delta-card-tip">Files with at least one line changed</div>
23027 </div>
23028 <div class="delta-card-inline">
23029 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23030 <div class="delta-card-lbl">files added</div>
23031 <div class="delta-card-tip">New files added since the previous scan</div>
23032 </div>
23033 <div class="delta-card-inline">
23034 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23035 <div class="delta-card-lbl">files removed</div>
23036 <div class="delta-card-tip">Files deleted since the previous scan</div>
23037 </div>
23038 <div class="delta-card-inline">
23039 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23040 <div class="delta-card-lbl">files unchanged</div>
23041 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23042 </div>
23043 </div>
23044 {% else %}
23045 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23046 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23047 </p>
23048 {% endif %}
23049 </div>
23050 <div class="compare-banner-actions">
23051 <div class="compare-banner-actions-left">
23052 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23053 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23054 </div>
23055 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23056 </div>
23057 </div>
23058 </div>
23059 {% endif %}{% endif %}
23060
23061 <div class="action-grid">
23062 <div class="action-card">
23063 <h3>HTML report</h3>
23064 <div class="action-buttons">
23065 {% match html_url %}
23066 {% when Some with (url) %}
23067 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23068 {% when None %}{% endmatch %}
23069 {% match html_download_url %}
23070 {% when Some with (url) %}
23071 <a class="button secondary" href="{{ url }}">Download HTML</a>
23072 {% when None %}{% endmatch %}
23073 {% match html_path %}
23074 {% when Some with (_path) %}{% when None %}{% endmatch %}
23075 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23076 </div>
23077 </div>
23078 <div class="action-card">
23079 <h3>PDF report</h3>
23080 <div class="action-buttons">
23081 {% match pdf_url %}
23082 {% when Some with (url) %}
23083 {% if pdf_generating %}
23084 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23085 <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>
23086 Generating PDF…
23087 </button>
23088 {% else %}
23089 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23090 {% endif %}
23091 {% when None %}
23092 {% match html_url %}
23093 {% when Some with (_hurl) %}
23094 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23095 <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>
23096 {% when None %}
23097 <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;">
23098 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23099 </p>
23100 {% endmatch %}
23101 {% endmatch %}
23102 {% match pdf_download_url %}
23103 {% when Some with (url) %}
23104 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23105 {% when None %}{% endmatch %}
23106 {% match pdf_url %}
23107 {% when Some with (_) %}
23108 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23109 {% when None %}{% endmatch %}
23110 </div>
23111 </div>
23112 <div class="action-card">
23113 <h3>JSON result</h3>
23114 <div class="action-buttons">
23115 {% match json_url %}
23116 {% when Some with (url) %}
23117 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23118 {% when None %}{% endmatch %}
23119 {% match json_download_url %}
23120 {% when Some with (url) %}
23121 <a class="button secondary" href="{{ url }}">Download JSON</a>
23122 {% when None %}{% endmatch %}
23123 {% match json_path %}
23124 {% when Some with (_path) %}
23125 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23126 {% when None %}
23127 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23128 {% endmatch %}
23129 </div>
23130 </div>
23131 <div class="action-card">
23132 <h3>Scan config</h3>
23133 <div class="action-buttons">
23134 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23135 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23136 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23137 </div>
23138 </div>
23139 {% if confluence_configured %}
23140 <div class="action-card" id="confluenceCard">
23141 <h3>Confluence</h3>
23142 <div class="action-buttons">
23143 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23144 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23145 </div>
23146 <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>
23147 </div>
23148 {% endif %}
23149 </div>
23150 {% if confluence_configured %}
23151 <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;">
23152 <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);">
23153 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23154 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23155 <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;">
23156 <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>
23157 <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;">
23158 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23159 <div style="display:flex;gap:10px;justify-content:flex-end;">
23160 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23161 <button class="button" id="confSubmitBtn" type="button">Post</button>
23162 </div>
23163 </div>
23164 </div>
23165 {% endif %}
23166 <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;">
23167 <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);">
23168 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23169 <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>
23170 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23171 <div style="display:flex;gap:18px;justify-content:flex-end;">
23172 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23173 <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>
23174 </div>
23175 </div>
23176 </div>
23177 {% if !submodule_rows.is_empty() %}
23178 <div class="submodule-panel">
23179 <div class="toolbar-row">
23180 <div>
23181 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23182 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23183 </div>
23184 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23185 </div>
23186 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23187 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23188 <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>
23189 <thead>
23190 <tr>
23191 <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>
23192 <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>
23193 <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>
23194 <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>
23195 <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>
23196 <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>
23197 <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>
23198 <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>
23199 </tr>
23200 </thead>
23201 <tbody>
23202 {% for row in submodule_rows %}
23203 <tr>
23204 <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>
23205 <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>
23206 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23207 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23208 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23209 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23210 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23211 <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>
23212 </tr>
23213 {% endfor %}
23214 </tbody>
23215 </table>
23216 </div>
23217 </div>
23218 {% endif %}
23219
23220 <div class="metrics-tables-stack">
23221
23222 <div class="metrics-table-wrap">
23223 <div class="metrics-table-title">Files</div>
23224 <table class="metrics-table">
23225 <thead>
23226 <tr>
23227 <th>Metric</th>
23228 <th>This Run</th>
23229 <th>Previous</th>
23230 <th>Change</th>
23231 </tr>
23232 </thead>
23233 <tbody>
23234 <tr>
23235 <td>Files analyzed</td>
23236 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23237 <td>{{ prev_fa_str|commas }}</td>
23238 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23239 </tr>
23240 <tr>
23241 <td>Files skipped</td>
23242 <td>{{ files_skipped|commas }}</td>
23243 <td>{{ prev_fs_str|commas }}</td>
23244 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23245 </tr>
23246 <tr>
23247 <td>Files modified</td>
23248 <td class="mt-val-na">—</td>
23249 <td class="mt-val-na">—</td>
23250 <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v|commas }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23251 </tr>
23252 <tr>
23253 <td>Files unchanged</td>
23254 <td class="mt-val-na">—</td>
23255 <td class="mt-val-na">—</td>
23256 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23257 </tr>
23258 </tbody>
23259 </table>
23260 </div>
23261
23262 <div class="metrics-table-wrap">
23263 <div class="metrics-table-title">Line Counts</div>
23264 <table class="metrics-table">
23265 <thead>
23266 <tr>
23267 <th>Metric</th>
23268 <th>This Run</th>
23269 <th>Previous</th>
23270 <th>Change</th>
23271 </tr>
23272 </thead>
23273 <tbody>
23274 <tr>
23275 <td>Physical lines</td>
23276 <td class="mt-val-large">{{ physical_lines|commas }}</td>
23277 <td>{{ prev_pl_str|commas }}</td>
23278 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23279 </tr>
23280 <tr>
23281 <td>Code lines</td>
23282 <td class="mt-val-large">{{ code_lines|commas }}</td>
23283 <td>{{ prev_cl_str|commas }}</td>
23284 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23285 </tr>
23286 <tr>
23287 <td>Comment lines</td>
23288 <td>{{ comment_lines|commas }}</td>
23289 <td>{{ prev_cml_str|commas }}</td>
23290 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23291 </tr>
23292 <tr>
23293 <td>Blank lines</td>
23294 <td>{{ blank_lines|commas }}</td>
23295 <td>{{ prev_bl_str|commas }}</td>
23296 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23297 </tr>
23298 <tr>
23299 <td>Mixed (separate)</td>
23300 <td>{{ mixed_lines|commas }}</td>
23301 <td class="mt-val-na">—</td>
23302 <td class="mt-val-na">—</td>
23303 </tr>
23304 </tbody>
23305 </table>
23306 </div>
23307
23308 <div class="metrics-tables-lower">
23309 <div class="metrics-table-wrap">
23310 <div class="metrics-table-title">Code Structure</div>
23311 <table class="metrics-table">
23312 <thead>
23313 <tr>
23314 <th>Metric</th>
23315 <th>This Run</th>
23316 </tr>
23317 </thead>
23318 <tbody>
23319 <tr>
23320 <td>Functions</td>
23321 <td>{{ functions|commas }}</td>
23322 </tr>
23323 <tr>
23324 <td>Classes / Types</td>
23325 <td>{{ classes|commas }}</td>
23326 </tr>
23327 <tr>
23328 <td>Variables</td>
23329 <td>{{ variables|commas }}</td>
23330 </tr>
23331 <tr>
23332 <td>Imports</td>
23333 <td>{{ imports|commas }}</td>
23334 </tr>
23335 </tbody>
23336 </table>
23337 </div>
23338
23339 <div class="metrics-table-wrap">
23340 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23341 <table class="metrics-table">
23342 <thead>
23343 <tr>
23344 <th>Metric</th>
23345 <th>Change</th>
23346 </tr>
23347 </thead>
23348 <tbody>
23349 <tr>
23350 <td>Lines added</td>
23351 <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23352 </tr>
23353 <tr>
23354 <td>Lines removed</td>
23355 <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">−{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23356 </tr>
23357 <tr>
23358 <td>Lines modified (net)</td>
23359 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23360 </tr>
23361 <tr>
23362 <td>Lines unmodified</td>
23363 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23364 </tr>
23365 </tbody>
23366 </table>
23367 </div>
23368 </div>
23369
23370 </div>
23371
23372 <div class="path-list">
23373 <div class="path-item">
23374 <div class="path-item-label">Project path</div>
23375 {% if project_path.is_empty() %}<code style="color:var(--muted)" title="The scanned project path was not recorded in this run's metadata.">Not recorded for this scan</code>{% else %}<code>{{ project_path }}</code>{% endif %}
23376 </div>
23377 <div class="path-item">
23378 <div class="path-item-label">Git branch</div>
23379 {% if let Some(branch) = git_branch %}
23380 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23381 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23382 {% else %}
23383 <code style="color:var(--muted)">—</code>
23384 {% endif %}
23385 </div>
23386 <div class="path-item">
23387 <div class="path-item-label">Output folder</div>
23388 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23389 </div>
23390 <div class="path-item">
23391 <div class="path-item-label">Run ID</div>
23392 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23393 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23394 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23395 </div>
23396 </div>
23397 </div>
23398 </section>
23399
23400 {% if has_cocomo %}
23401 <div class="cocomo-box" style="margin-top:24px;">
23402 <div class="cocomo-box-head">
23403 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
23404 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23405 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23406 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23407 </span>
23408 </div>
23409 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23410 <div class="stat-chip">
23411 <div class="stat-chip-label">Person-months</div>
23412 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23413 <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>
23414 </div>
23415 <div class="stat-chip">
23416 <div class="stat-chip-label">Schedule (months)</div>
23417 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23418 <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>
23419 </div>
23420 <div class="stat-chip">
23421 <div class="stat-chip-label">Avg. Team Size</div>
23422 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23423 <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>
23424 </div>
23425 <div class="stat-chip">
23426 <div class="stat-chip-label">Input KSLOC</div>
23427 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23428 <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>
23429 </div>
23430 </div>
23431 <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>
23432 </div>
23433 {% endif %}
23434
23435 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23436 <div class="cocomo-box" style="margin-top:24px;">
23437 <div class="cocomo-box-head">
23438 <span class="cocomo-box-title">Tests & Coverage</span>
23439 {% if has_coverage_data %}
23440 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23441 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23442 </span>
23443 {% endif %}
23444 </div>
23445 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23446 <div class="stat-chip">
23447 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23448 <div class="stat-chip-label">Test Functions</div>
23449 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23450 </div>
23451 <div class="stat-chip">
23452 {% if has_coverage_data %}
23453 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23454 {% else %}
23455 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23456 {% endif %}
23457 <div class="stat-chip-label">Line Coverage</div>
23458 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23459 </div>
23460 <div class="stat-chip">
23461 {% if !cov_fn_pct.is_empty() %}
23462 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23463 {% else %}
23464 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23465 {% endif %}
23466 <div class="stat-chip-label">Fn Coverage</div>
23467 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23468 </div>
23469 <div class="stat-chip">
23470 {% if !cov_branch_pct.is_empty() %}
23471 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23472 {% else %}
23473 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23474 {% endif %}
23475 <div class="stat-chip-label">Branch Coverage</div>
23476 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23477 </div>
23478 </div>
23479 {% if has_coverage_data %}
23480 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
23481 {% else %}
23482 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
23483 {% endif %}
23484 </div>
23485
23486 <div class="section-pair">
23487 <section class="panel">
23488 <div class="toolbar-row">
23489 <div>
23490 <h2>Language Breakdown</h2>
23491 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23492 </div>
23493 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23494 </div>
23495 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23496 </section>
23497
23498 <section class="panel r-chart-section">
23499 <div class="toolbar-row" style="margin-bottom:16px;">
23500 <div>
23501 <h2>Visualizations</h2>
23502 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23503 </div>
23504 </div>
23505
23506 <div class="r-viz-grid">
23507 <div class="r-viz-card">
23508 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23509 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23510 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23511 </div>
23512 <div class="r-chart-tab-bar">
23513 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23514 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23515 </div>
23516 <div class="r-chart-container" id="r-composition-chart"></div>
23517 </div>
23518 <div class="r-viz-card">
23519 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23520 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23521 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23522 </div>
23523 <div class="r-chart-container" id="r-scatter-chart"></div>
23524 </div>
23525 {% if has_semantic_data %}
23526 <div class="r-viz-card">
23527 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23528 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23529 <select class="r-chart-select" id="r-semantic-metric">
23530 <option value="functions">Functions</option>
23531 <option value="classes">Classes</option>
23532 <option value="variables">Variables</option>
23533 <option value="imports">Imports</option>
23534 <option value="tests">Tests</option>
23535 </select>
23536 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23537 </div>
23538 <div class="r-chart-container" id="r-semantic-chart"></div>
23539 </div>
23540 {% endif %}
23541 <div class="r-viz-card">
23542 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23543 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23544 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23545 </div>
23546 <div class="r-chart-container" id="r-density-chart"></div>
23547 </div>
23548 <div class="r-viz-card">
23549 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23550 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23551 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23552 </div>
23553 <div class="r-chart-container" id="r-avglines-chart"></div>
23554 </div>
23555 <div class="r-viz-card">
23556 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23557 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23558 <select class="r-chart-select" id="r-sub-metric">
23559 <option value="code">Code Lines</option>
23560 <option value="comment">Comments</option>
23561 <option value="blank">Blank Lines</option>
23562 <option value="physical">Physical Lines</option>
23563 <option value="files">Files</option>
23564 </select>
23565 <select class="r-chart-select" id="r-sub-sort">
23566 <option value="desc">Value ↓</option>
23567 <option value="asc">Value ↑</option>
23568 <option value="name">Name A→Z</option>
23569 </select>
23570 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23571 </div>
23572 <div class="r-chart-container" id="r-submodule-chart"></div>
23573 </div>
23574 </div>
23575
23576 </section>
23577 </div>
23578
23579 </div>
23580
23581 <div id="r-tt" aria-hidden="true"></div>
23582
23583 <script nonce="{{ csp_nonce }}">
23584 (function () {
23585 var body = document.body;
23586 var themeToggle = document.getElementById('theme-toggle');
23587 var storageKey = 'oxide-sloc-theme';
23588
23589 function applyTheme(theme) {
23590 body.classList.toggle('dark-theme', theme === 'dark');
23591 }
23592
23593 function loadSavedTheme() {
23594 try {
23595 var saved = localStorage.getItem(storageKey);
23596 if (saved === 'dark' || saved === 'light') {
23597 applyTheme(saved);
23598 }
23599 } catch (e) {}
23600 }
23601
23602 if (themeToggle) {
23603 themeToggle.addEventListener('click', function () {
23604 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
23605 applyTheme(nextTheme);
23606 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
23607 });
23608 }
23609
23610 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
23611 button.addEventListener('click', function () {
23612 var value = button.getAttribute('data-copy-value') || '';
23613 if (!value) return;
23614 var originalText = button.textContent;
23615 function flashSuccess() {
23616 button.textContent = 'Copied!';
23617 setTimeout(function () { button.textContent = originalText; }, 1800);
23618 }
23619 function flashFail() {
23620 button.textContent = 'Copy failed';
23621 setTimeout(function () { button.textContent = originalText; }, 2000);
23622 }
23623 if (navigator.clipboard && navigator.clipboard.writeText) {
23624 navigator.clipboard.writeText(value).then(flashSuccess, function () {
23625 fallbackCopy(value, flashSuccess, flashFail);
23626 });
23627 } else {
23628 fallbackCopy(value, flashSuccess, flashFail);
23629 }
23630 });
23631 });
23632 function fallbackCopy(text, onSuccess, onFail) {
23633 try {
23634 var ta = document.createElement('textarea');
23635 ta.value = text;
23636 ta.style.position = 'fixed';
23637 ta.style.top = '-9999px';
23638 ta.style.left = '-9999px';
23639 document.body.appendChild(ta);
23640 ta.focus();
23641 ta.select();
23642 var ok = document.execCommand('copy');
23643 document.body.removeChild(ta);
23644 if (ok) { onSuccess(); } else { onFail(); }
23645 } catch (e) { onFail(); }
23646 }
23647
23648 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
23649 btn.addEventListener('click', function () {
23650 var folder = btn.getAttribute('data-folder') || '';
23651 if (!folder) return;
23652 var orig = btn.textContent;
23653 fetch('/open-path?path=' + encodeURIComponent(folder))
23654 .then(function (r) { return r.json(); })
23655 .then(function (d) {
23656 if (d && d.server_mode_disabled) {
23657 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23658 } else if (d && d.ok) {
23659 btn.textContent = 'Opened!';
23660 setTimeout(function () { btn.textContent = orig; }, 1800);
23661 }
23662 })
23663 .catch(function () {
23664 btn.textContent = 'Failed';
23665 setTimeout(function () { btn.textContent = orig; }, 2000);
23666 });
23667 });
23668 });
23669
23670 loadSavedTheme();
23671
23672 // ── Compact number formatting for stat chips ──────────────────────────
23673 (function(){
23674 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();}
23675 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
23676 var raw=parseInt(chip.getAttribute('data-raw'),10);
23677 if(isNaN(raw))return;
23678 var valEl=chip.querySelector('.stat-chip-val');
23679 if(valEl)valEl.textContent=fmt(raw);
23680 var exactEl=chip.querySelector('.stat-chip-exact');
23681 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
23682 });
23683 // Code density chip
23684 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
23685 var code=parseInt(chip.getAttribute('data-code'),10);
23686 var phys=parseInt(chip.getAttribute('data-physical'),10);
23687 if(isNaN(code)||isNaN(phys)||phys===0)return;
23688 var pct=(code/phys*100).toFixed(1)+'%';
23689 var valEl=chip.querySelector('.stat-chip-val');
23690 if(valEl)valEl.textContent=pct;
23691 });
23692 // Populate author handle from data-author attribute
23693 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
23694 var author=chip.getAttribute('data-author');
23695 var el=chip.querySelector('.author-handle');
23696 if(el)el.textContent='/'+author.replace(/\s+/g,'');
23697 });
23698 // Click-to-copy on run-id-chip elements
23699 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
23700 chip.addEventListener('click',function(){
23701 var val=chip.getAttribute('data-copy');
23702 if(!val)return;
23703 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
23704 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);}
23705 chip.classList.add('chip-copied-flash');
23706 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
23707 });
23708 });
23709 // Format delta card values with data-raw using comma-separated full numbers
23710 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
23711 var raw=parseInt(card.getAttribute('data-raw'),10);
23712 if(isNaN(raw))return;
23713 var valEl=card.querySelector('.delta-card-val');
23714 if(valEl)valEl.textContent=raw.toLocaleString();
23715 });
23716 // Format code-before / code-now numbers in the compare banner stats line
23717 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
23718 var raw=parseInt(el.getAttribute('data-raw'),10);
23719 if(!isNaN(raw))el.textContent=raw.toLocaleString();
23720 });
23721 })();
23722
23723 // ── Shared tooltip for all result-page charts ─────────────────────────
23724 var rTT=(function(){
23725 var el=document.getElementById('r-tt');
23726 if(!el)return{s:function(){},h:function(){},m:function(){}};
23727 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
23728 function hide(){el.style.display='none';}
23729 function move(e){
23730 var x=e.clientX+16,y=e.clientY-12;
23731 var r=el.getBoundingClientRect();
23732 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
23733 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
23734 el.style.left=x+'px';el.style.top=y+'px';
23735 }
23736 return{s:show,h:hide,m:move};
23737 })();
23738 window.rTT=rTT;
23739
23740 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
23741 (function(){
23742 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23743 document.addEventListener('mouseover',function(e){
23744 var t=e.target;
23745 while(t&&t.getAttribute){
23746 var l=t.getAttribute('data-ttl');
23747 if(l!==null){
23748 var v=t.getAttribute('data-ttv')||'';
23749 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
23750 return;
23751 }
23752 t=t.parentNode;
23753 }
23754 });
23755 document.addEventListener('mouseout',function(e){
23756 var t=e.target;
23757 while(t&&t.getAttribute){
23758 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
23759 t=t.parentNode;
23760 }
23761 });
23762 document.addEventListener('mousemove',function(e){
23763 var el=document.getElementById('r-tt');
23764 if(el&&el.style.display!=='none')rTT.m(e);
23765 });
23766 window.addEventListener('blur',function(){rTT.h();});
23767 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
23768 })();
23769
23770 // ── Language overview charts ───────────────────────────────────────────
23771 (function(){
23772 var D={{ lang_chart_json|safe }};
23773 if(!D||!D.length)return;
23774 var el=document.getElementById('result-lang-charts');
23775 if(!el)return;
23776 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23777 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
23778 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23779 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();}
23780 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23781 function px(n){return Math.round(n);}
23782 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+'"';}
23783 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
23784 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
23785 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
23786 function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
23787 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
23788
23789 // Donut chart — height matches the stacked-bar chart so both panels align
23790 var rHb_d=28;
23791 var DH=Math.max(220,D.length*rHb_d+32);
23792 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
23793 var legX=208,DW=395;
23794 var legCount=D.length;
23795 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
23796 var legYStart=Math.round((DH-legCount*legSpacing)/2);
23797 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">';
23798 if(D.length===1){
23799 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
23800 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+'"/>';
23801 } else {
23802 var smalls=[];
23803 var ang=-Math.PI/2;
23804 D.forEach(function(d,i){
23805 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23806 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
23807 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
23808 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
23809 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
23810 var pct=Math.round(d.code/tot*100);
23811 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"/>';
23812 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
23813 ang+=sw;
23814 });
23815 // Small slices (<5%) get outside labels positioned near each slice's own
23816 // angular position (a slice on the left gets its label/leader on the left),
23817 // then nudged apart horizontally so text never overlaps. Leader lines point
23818 // from each slice to its label. Horizontal text keeps long names legible;
23819 // the whole SVG scales up in Full View so these stay readable there too.
23820 if(smalls.length){
23821 smalls.sort(function(a,b){return a.mAng-b.mAng;});
23822 var sPad=6,sRowY=11;
23823 smalls.forEach(function(sm){sm.txt=sm.lang+' '+sm.pct+'%';sm.w=sm.txt.length*5+8;sm.x=Math.max(sPad+sm.w/2,Math.min(DW-sPad-sm.w/2,cx+(Ro+14)*Math.cos(sm.mAng)));});
23824 for(var si=1;si<smalls.length;si++){var mnX=smalls[si-1].x+smalls[si-1].w/2+smalls[si].w/2+3;if(smalls[si].x<mnX)smalls[si].x=mnX;}
23825 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
23826 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
23827 smalls.forEach(function(sm){
23828 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
23829 ds+='<line x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
23830 ds+='<text x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="pointer-events:none;">'+esc(sm.txt)+'</text>';
23831 });
23832 }
23833 }
23834 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
23835 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
23836 D.forEach(function(d,i){
23837 var ly=legYStart+i*legSpacing;
23838 var pctL=Math.round(d.code/tot*100);
23839 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
23840 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
23841 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
23842 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
23843 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
23844 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
23845 ds+='<text x="'+(legX+100)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(10,legSpacing-3)+'" font-weight="700" fill="#7b675b">'+fmt(d.code)+' ('+pctL+'%)</text>';
23846 ds+='</g>';
23847 });
23848 ds+='</svg>';
23849
23850 // Horizontal stacked-bar chart — fills container width
23851 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
23852 var LW=108,BW=260,svgW=LW+BW+68;
23853 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
23854 var barBH=Math.min(32,Math.round(barRhb*0.7));
23855 var SH=DH;
23856 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
23857 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">';
23858 D.forEach(function(d,i){
23859 var y=barTopPad+i*barRhb,x=LW;
23860 var phys=d.physical||d.code+d.comments+d.blanks;
23861 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
23862 var lmid=y+barBH/2+4;
23863 // Combined breakdown shown when hovering the row, the language name, or the
23864 // total at the bar end (\n becomes a line break in the tooltip).
23865 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
23866 bs+='<g class="lang-bar-row">';
23867 // Hit area ends just past the total label so empty space to the right of the
23868 // bar does not trigger the tooltip — only the name, bar and total are hot.
23869 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
23870 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
23871 bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
23872 if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'" rx="0"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
23873 if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'" rx="0"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
23874 if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'" rx="0"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
23875 bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
23876 bs+='</g>';
23877 });
23878 var ly=SH-14;
23879 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
23880 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
23881 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
23882 var totAll=totC+totCm+totBl||1;
23883 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
23884 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
23885 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
23886 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
23887 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
23888 bs+='<g data-kind="code" style="cursor:pointer;">'
23889 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
23890 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
23891 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
23892 +'</g>';
23893 bs+='<g data-kind="comment" style="cursor:pointer;">'
23894 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
23895 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
23896 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
23897 +'</g>';
23898 bs+='<g data-kind="blank" style="cursor:pointer;">'
23899 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
23900 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
23901 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
23902 +'</g>';
23903 bs+='</svg>';
23904 el.innerHTML='<div class="r-lang-overview">'+
23905 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
23906 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
23907 '</div>';
23908 function wireDonutLegend(svg){
23909 if(!svg)return;
23910 var paths=svg.querySelectorAll('path[data-lang]');
23911 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';}}}
23912 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
23913 svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}rst();});
23914 svg.addEventListener('mousemove',function(e){var t=e.target;while(t&&t!==svg){if(t.getAttribute&&t.getAttribute('data-lang'))return;t=t.parentNode;}rst();});
23915 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
23916 }
23917 function wireMixLegend(svg){
23918 if(!svg)return;
23919 var legGs=svg.querySelectorAll('g[data-kind]');
23920 var allRects=svg.querySelectorAll('rect[data-kind]');
23921 if(!legGs.length)return;
23922 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';}}
23923 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='';}}
23924 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]);}
23925 }
23926 wireDonutLegend(el.querySelector('svg'));
23927 wireMixLegend(el.querySelectorAll('svg')[1]);
23928
23929 // ── Language breakdown Full View expand ─────────────────────────────────
23930 var langOvBtn=document.getElementById('result-lang-overview-expand');
23931 if(langOvBtn){langOvBtn.addEventListener('click',function(){
23932 var src=document.getElementById('result-lang-charts');
23933 if(!src)return;
23934 var overlay=document.createElement('div');
23935 overlay.className='r-chart-modal-overlay';
23936 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
23937 document.body.appendChild(overlay);
23938 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
23939 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
23940 var wrap=document.getElementById('result-lang-overview-modal-wrap');
23941 if(wrap){
23942 wrap.innerHTML=src.innerHTML;
23943 var svgs=wrap.querySelectorAll('svg');
23944 for(var i=0;i<svgs.length;i++){
23945 svgs[i].removeAttribute('width');
23946 svgs[i].removeAttribute('height');
23947 svgs[i].style.cssText='display:block;width:100%;height:auto;';
23948 }
23949 var ov=wrap.querySelector('.r-lang-overview');
23950 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
23951 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
23952 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
23953 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
23954 wireDonutLegend(wrap.querySelector('svg'));
23955 wireMixLegend(wrap.querySelectorAll('svg')[1]);
23956 requestAnimationFrame(function(){
23957 var ss=wrap.querySelectorAll('svg');
23958 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%;';}}
23959 });
23960 }
23961 });}
23962 })();
23963
23964 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
23965 (function(){
23966 var LANG_D={{ lang_chart_json|safe }};
23967 var SCAT_D={{ scatter_chart_json|safe }};
23968 var SEM_D={{ semantic_chart_json|safe }};
23969 var SUB_D={{ submodule_chart_json|safe }};
23970 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
23971 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23972 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();}
23973 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23974 function px(n){return Math.round(n);}
23975 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+'"';}
23976 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
23977 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
23978 // rather than disappear; the SVG scales up in Full View).
23979 function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
23980
23981 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
23982 function renderCompositionInEl(el,mode,shOvr){
23983 if(!el||!LANG_D||!LANG_D.length)return;
23984 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23985 var LW=110,SH=shOvr||300;
23986 var svgW=Math.max(320,el.offsetWidth||480);
23987 var BW=Math.max(120,svgW-LW-80);
23988 var legendH=24,topPad=4;
23989 var n=LANG_D.length||1;
23990 var rowTotal=Math.floor((SH-legendH-topPad)/n);
23991 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
23992 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">';
23993 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
23994 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
23995 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
23996 var totAll2=totC2+totCm2+totBl2||1;
23997 if(mode==='pct'){
23998 LANG_D.forEach(function(d,i){
23999 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24000 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24001 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24002 var lmid=y+Math.floor(bH/2)+4;
24003 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24004 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24005 if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
24006 if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
24007 if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
24008 var pct=Math.round((d.code||0)/tot2*100);
24009 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+pct+'%</text>';
24010 });
24011 } else {
24012 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24013 LANG_D.forEach(function(d,i){
24014 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24015 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24016 var lmid=y+Math.floor(bH/2)+4;
24017 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0));
24018 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24019 if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
24020 if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
24021 if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
24022 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
24023 });
24024 }
24025 var ly=SH-legendH+4;
24026 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24027 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24028 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24029 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24030 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24031 s+='<g data-kind="code" style="cursor:pointer;">'
24032 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24033 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24034 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24035 +'</g>';
24036 s+='<g data-kind="comment" style="cursor:pointer;">'
24037 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24038 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24039 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24040 +'</g>';
24041 s+='<g data-kind="blank" style="cursor:pointer;">'
24042 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24043 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24044 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24045 +'</g>';
24046 s+='</svg>';
24047 el.innerHTML=s;
24048 wireMixLegendEl(el);
24049 }
24050 function wireMixLegendEl(container){
24051 var svg=container&&container.querySelector('svg');
24052 if(!svg)return;
24053 var legGs=svg.querySelectorAll('g[data-kind]');
24054 var allRects=svg.querySelectorAll('rect[data-kind]');
24055 if(!legGs.length)return;
24056 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';}}
24057 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='';}}
24058 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]);}
24059 }
24060 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24061 renderComposition('abs');
24062 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24063 btn.addEventListener('click',function(){
24064 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24065 btn.classList.add('active');
24066 renderComposition(btn.getAttribute('data-rcomp'));
24067 });
24068 });
24069
24070 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24071 function wireScatterLegend(container){
24072 var svg=container&&container.querySelector('svg');
24073 if(!svg)return;
24074 var legGs=svg.querySelectorAll('g[data-lang]');
24075 var circs=svg.querySelectorAll('circle[data-lang]');
24076 if(!legGs.length)return;
24077 function hl(lang){for(var i=0;i<circs.length;i++){var c=circs[i];if(c.getAttribute('data-lang')===lang){c.style.opacity='1';c.style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.28))';}else{c.style.opacity='0.12';c.style.filter='none';}}
24078 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24079 function rst(){for(var i=0;i<circs.length;i++){circs[i].style.opacity='';circs[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24080 for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hl(g.getAttribute('data-lang'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
24081 }
24082 function renderScatterInEl(el,hOvr){
24083 if(!el||!SCAT_D||!SCAT_D.length)return;
24084 var n=SCAT_D.length;
24085 var H=hOvr||300,PL=52,PB=36,PT=44;
24086 var W=Math.max(320,el.offsetWidth||480);
24087 var cH=H-PT-PB;
24088 // Legend: max 2 columns, fills vertical space. The compact card shows the
24089 // top languages by code lines plus a "+N more" row linking to Full View;
24090 // Full View (hOvr set) shows every language across up to 2 tall columns.
24091 var compact=!hOvr;
24092 var availH=Math.max(120,H-24);
24093 var rowsFit=Math.max(2,Math.floor(availH/18));
24094 var legTrunc=compact&&(n>2*rowsFit);
24095 var legShown=legTrunc?(2*rowsFit-1):n;
24096 var legTotal=legTrunc?(2*rowsFit):n;
24097 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24098 var legPerCol=Math.ceil(legTotal/legCols);
24099 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24100 var legColW=hOvr?144:130;
24101 var LG=26;
24102 var legW=legCols*legColW;
24103 var cW=W-PL-LG-legW;
24104 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24105 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24106 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24107 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24108 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24109 var logMaxF=Math.log1p(maxF);
24110 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">';
24111 // Y grid lines (linear)
24112 [0,0.25,0.5,0.75,1].forEach(function(t){
24113 var y=PT+cH*(1-t);
24114 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24115 if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(Math.round(maxC*t))+'</text>';
24116 });
24117 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24118 [0,0.25,0.5,0.75,1].forEach(function(t){
24119 var x=PL+cW*t;
24120 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24121 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24122 if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(xVal)+'</text>';
24123 });
24124 // Full View (hOvr set) has the vertical room to show the per-bubble value
24125 // line; the compact card shows only the language label to avoid the
24126 // overlapping-label clutter seen when bubbles cluster together.
24127 var showVal=!!hOvr;
24128 SCAT_D.forEach(function(d,i){
24129 // X uses log1p so outlier languages (many files) don't push others to the far left
24130 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24131 var cy2=PT+cH-d.code/maxC*cH;
24132 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24133 s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' data-lang="'+esc(d.lang)+'" cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
24134 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24135 if(showVal){
24136 var ty2=Math.max(24,px(cy2)-px(r)-3);
24137 var ty1=Math.max(12,ty2-14);
24138 s+='<text x="'+px(cx2)+'" y="'+ty1+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24139 s+='<text x="'+px(cx2)+'" y="'+ty2+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor" opacity="0.88" style="pointer-events:none;">'+fmt(d.code)+'</text>';
24140 }else{
24141 var ly2=Math.max(12,px(cy2)-px(r)-3);
24142 s+='<text x="'+px(cx2)+'" y="'+ly2+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24143 }
24144 });
24145 s+='<text x="'+(PL+cW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75">Files Analyzed</text>';
24146 s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
24147 // Legend (right side — top languages, max 2 columns, fills height)
24148 var legX=PL+cW+LG;
24149 var legBlockH=legPerCol*legRowH;
24150 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24151 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24152 for(var lk=0;lk<legShown;lk++){
24153 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24154 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24155 s+='<g data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" style="cursor:pointer;">';
24156 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24157 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24158 s+='<text x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" style="pointer-events:none;">'+esc(ld.lang)+'</text>';
24159 s+='</g>';
24160 }
24161 if(legTrunc){
24162 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24163 s+='<g data-more="1" style="cursor:pointer;">';
24164 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24165 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24166 s+='<text x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" style="pointer-events:none;">+'+(n-legShown)+' more</text>';
24167 s+='</g>';
24168 }
24169 s+='</svg>';
24170 el.innerHTML=s;
24171 wireScatterLegend(el);
24172 var moreEl=el.querySelector('g[data-more]');
24173 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24174 }
24175 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24176
24177 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24178 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24179 // the old vertical column layout on wide containers.
24180 function renderSemanticInEl(el,key,sh){
24181 if(!el||!SEM_D||!SEM_D.length)return;
24182 var n2=SEM_D.length||1;
24183 var LW=112,SH=sh||Math.max(180,n2*28+26);
24184 var svgW=Math.max(320,el.offsetWidth||480);
24185 var BW=Math.max(120,svgW-LW-80);
24186 var topPad=4,botPad=14;
24187 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24188 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24189 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24190 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">';
24191 SEM_D.forEach(function(d,i){
24192 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24193 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>';
24194 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"/>';
24195 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>';
24196 });
24197 s+='</svg>';
24198 el.innerHTML=s;
24199 }
24200 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24201 var semSel=document.getElementById('r-semantic-metric');
24202 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24203 var semExpand=document.getElementById('r-semantic-expand');
24204 if(semExpand){
24205 semExpand.addEventListener('click',function(){
24206 var key=semSel?semSel.value:'functions';
24207 var n=SEM_D.length||1;
24208 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24209 var modalH=Math.min(Math.max(360,n*38+60),maxH);
24210 var overlay=document.createElement('div');
24211 overlay.className='r-chart-modal-overlay';
24212 var optHtml=
24213 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24214 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24215 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24216 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24217 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24218 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics \u2014 Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" class="r-expand-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
24219 document.body.appendChild(overlay);
24220 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24221 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24222 var modalEl=document.getElementById('r-sem-modal-chart');
24223 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24224 var modalSel=document.getElementById('r-sem-modal-metric');
24225 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24226 });
24227 }
24228
24229 // ── Expand buttons: re-render charts at large size inside modal ──────────
24230 (function(){
24231 function makeExpandModal(title,mH,subtitle,ctrlHtml){
24232 var overlay=document.createElement('div');
24233 overlay.className='r-chart-modal-overlay';
24234 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24235 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24236 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>';
24237 document.body.appendChild(overlay);
24238 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24239 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24240 return overlay.querySelector('.r-expand-modal-chart');
24241 }
24242 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24243 var compExpandBtn=document.getElementById('r-composition-expand');
24244 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24245 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24246 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24247 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24248 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24249 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24250 if(wrap){
24251 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24252 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24253 btn.addEventListener('click',function(){
24254 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24255 btn.classList.add('active');
24256 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24257 });
24258 });
24259 }
24260 });}
24261 var scatExpandBtn=document.getElementById('r-scatter-expand');
24262 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24263 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24264 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24265 });}
24266 var densExpandBtn=document.getElementById('r-density-expand');
24267 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24268 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24269 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24270 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24271 });}
24272 var avgExpandBtn=document.getElementById('r-avglines-expand');
24273 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24274 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24275 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24276 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24277 });}
24278 var subExpandBtn=document.getElementById('r-submodule-expand');
24279 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24280 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24281 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24282 var metCtrl=
24283 '<select class="r-chart-select" id="r-sub-modal-metric">'
24284 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24285 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24286 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24287 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24288 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24289 +'</select>';
24290 var sortCtrl=
24291 '<select class="r-chart-select" id="r-sub-modal-sort">'
24292 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24293 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24294 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24295 +'</select>';
24296 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24297 if(wrap){
24298 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24299 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24300 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24301 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24302 if(mSub)mSub.addEventListener('change',reRenderSub);
24303 if(mSort)mSort.addEventListener('change',reRenderSub);
24304 }
24305 });}
24306 })();
24307
24308 // ── Comment Density: comments / (code + comments) per language ───────────
24309 function renderDensityInEl(el,shOvr){
24310 if(!el||!LANG_D||!LANG_D.length)return;
24311 var n=LANG_D.length||1;
24312 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24313 var svgW=Math.max(320,el.offsetWidth||480);
24314 var BW=Math.max(120,svgW-LW-80);
24315 var topPad=4,botPad=26;
24316 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24317 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24318 var densities=LANG_D.map(function(d){
24319 var sig=(d.code||0)+(d.comments||0);
24320 return sig>0?(d.comments||0)/sig:0;
24321 });
24322 var maxDen=Math.max.apply(null,densities)||1;
24323 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">';
24324 LANG_D.forEach(function(d,i){
24325 var den=densities[i],bw=den/maxDen*BW;
24326 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24327 var pct=Math.round(den*100);
24328 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>';
24329 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"/>';
24330 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24331 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>';
24332 });
24333 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>';
24334 s+='</svg>';
24335 el.innerHTML=s;
24336 }
24337 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24338 renderDensity();
24339
24340 // ── Avg Lines per File: code / files per language ─────────────────────
24341 function renderAvgLinesInEl(el,shOvr){
24342 if(!el||!LANG_D||!LANG_D.length)return;
24343 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24344 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24345 var n=data.length||1;
24346 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24347 var svgW=Math.max(320,el.offsetWidth||480);
24348 var BW=Math.max(120,svgW-LW-80);
24349 var topPad=4,botPad=26;
24350 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24351 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24352 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24353 var maxAvg=Math.max.apply(null,avgs)||1;
24354 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">';
24355 data.forEach(function(d,i){
24356 var avg=avgs[i],bw=avg/maxAvg*BW;
24357 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24358 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>';
24359 if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file \u00b7 '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
24360 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24361 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>';
24362 });
24363 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>';
24364 s+='</svg>';
24365 el.innerHTML=s;
24366 }
24367 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24368 renderAvgLines();
24369
24370 // ── Repository Overview: overall row + per-submodule rows ────────────
24371 function renderSubmoduleInEl(el,key,sort,shOvr){
24372 if(!el)return;
24373 var overall={
24374 name:'Overall',
24375 code:{{ code_lines }},
24376 comment:{{ comment_lines }},
24377 blank:{{ blank_lines }},
24378 physical:{{ physical_lines }},
24379 files:{{ files_analyzed }},
24380 isOverall:true
24381 };
24382 var subs=SUB_D.slice();
24383 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24384 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24385 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24386 var data=[overall].concat(subs);
24387 var sepH=subs.length>0?14:0;
24388 var naturalH=data.length*32+sepH+16;
24389 var SH=shOvr||Math.max(100,naturalH);
24390 var svgW=Math.max(320,el.offsetWidth||480);
24391 var LW=116,BW=Math.max(200,svgW-LW-54);
24392 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24393 var OVERALL_COL='#6b7280';
24394 var topPad=4,botPad=8;
24395 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24396 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24397 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">';
24398 var yOff=topPad;
24399 data.forEach(function(d,i){
24400 var v=d[key]||0,bw=v/maxV*BW;
24401 var y=yOff+Math.floor((rowSlot-bH)/2);
24402 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24403 var label=d.name||d.path||'?';
24404 s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
24405 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24406 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24407 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
24408 yOff+=rowSlot;
24409 if(d.isOverall&&subs.length>0){
24410 yOff+=sepH;
24411 }
24412 });
24413 s+='</svg>';
24414 el.innerHTML=s;
24415 }
24416 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24417 var subSel=document.getElementById('r-sub-metric');
24418 var sortSel=document.getElementById('r-sub-sort');
24419 renderSubmodule('code','desc');
24420 if(subSel){
24421 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24422 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24423 }
24424
24425 // Equalise heights within each chart row: if one chart in a grid row is taller
24426 // than its neighbour, re-render the shorter one at the taller height so bars fill
24427 // the available vertical space instead of leaving a gap.
24428 function syncRowHeights(){
24429 var avgEl=document.getElementById('r-avglines-chart');
24430 var subEl=document.getElementById('r-submodule-chart');
24431 if(avgEl&&subEl){
24432 var avgSvg=avgEl.querySelector('svg');
24433 var subSvg=subEl.querySelector('svg');
24434 if(avgSvg&&subSvg){
24435 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24436 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24437 var key=subSel?subSel.value||'code':'code';
24438 var sort=sortSel?sortSel.value:'desc';
24439 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24440 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24441 }
24442 }
24443 var semEl=document.getElementById('r-semantic-chart');
24444 var denEl=document.getElementById('r-density-chart');
24445 if(semEl&&denEl){
24446 var semSvg=semEl.querySelector('svg');
24447 var denSvg=denEl.querySelector('svg');
24448 if(semSvg&&denSvg){
24449 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24450 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24451 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24452 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24453 }
24454 }
24455 }
24456 syncRowHeights();
24457
24458 // Re-render all SVG charts when the window is resized so bars fill the card.
24459 var _rResizeTimer;
24460 window.addEventListener('resize',function(){
24461 clearTimeout(_rResizeTimer);
24462 _rResizeTimer=setTimeout(function(){
24463 var rcompBtn=document.querySelector('[data-rcomp].active');
24464 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24465 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24466 if(semSel)renderSemantic(semSel.value||'functions');
24467 renderDensity();
24468 renderAvgLines();
24469 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24470 syncRowHeights();
24471 },120);
24472 });
24473 })();
24474
24475 (function randomizeWatermarks() {
24476 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24477 if (!wms.length) return;
24478 var placed = [];
24479 function tooClose(top, left) {
24480 for (var i = 0; i < placed.length; i++) {
24481 var dt = Math.abs(placed[i][0] - top);
24482 var dl = Math.abs(placed[i][1] - left);
24483 if (dt < 20 && dl < 18) return true;
24484 }
24485 return false;
24486 }
24487 function pick(leftBand) {
24488 for (var attempt = 0; attempt < 50; attempt++) {
24489 var top = Math.random() * 85 + 5;
24490 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24491 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24492 }
24493 var top = Math.random() * 85 + 5;
24494 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24495 placed.push([top, left]);
24496 return [top, left];
24497 }
24498 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24499 var half = Math.floor(wms.length / 2);
24500 wms.forEach(function (img, i) {
24501 var pos = pick(i < half);
24502 var size = Math.floor(Math.random() * 100 + 160);
24503 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24504 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24505 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;
24506 });
24507 })();
24508
24509 (function spawnCodeParticles() {
24510 var container = document.getElementById('code-particles');
24511 if (!container) return;
24512 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'];
24513 for (var i = 0; i < 38; i++) {
24514 (function(idx) {
24515 var el = document.createElement('span');
24516 el.className = 'code-particle';
24517 el.textContent = snippets[idx % snippets.length];
24518 var left = Math.random() * 94 + 2;
24519 var top = Math.random() * 88 + 6;
24520 var dur = (Math.random() * 10 + 9).toFixed(1);
24521 var delay = (Math.random() * 18).toFixed(1);
24522 var rot = (Math.random() * 26 - 13).toFixed(1);
24523 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24524 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';
24525 container.appendChild(el);
24526 })(i);
24527 }
24528 })();
24529
24530 {% if pdf_generating %}
24531 // Poll for PDF readiness and swap the disabled button to a live link once done.
24532 (function() {
24533 var openBtn = document.getElementById('pdf-open-btn');
24534 var dlBtn = document.getElementById('pdf-download-btn');
24535 function checkPdf() {
24536 fetch('/api/runs/{{ run_id }}/pdf-status')
24537 .then(function(r) { return r.json(); })
24538 .then(function(d) {
24539 if (d.ready) {
24540 if (openBtn) {
24541 var a = document.createElement('a');
24542 a.className = 'button';
24543 a.id = 'pdf-open-btn';
24544 a.href = '/runs/pdf/{{ run_id }}';
24545 a.target = '_blank';
24546 a.rel = 'noopener';
24547 a.textContent = 'Open PDF';
24548 openBtn.replaceWith(a);
24549 }
24550 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24551 } else {
24552 setTimeout(checkPdf, 3000);
24553 }
24554 })
24555 .catch(function() { setTimeout(checkPdf, 5000); });
24556 }
24557 setTimeout(checkPdf, 3000);
24558 })();
24559 {% endif %}
24560
24561 })();
24562 </script>
24563 <script nonce="{{ csp_nonce }}">
24564 (function(){
24565 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'}];
24566 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);});}
24567 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24568 function init(){
24569 var btn=document.getElementById('settings-btn');if(!btn)return;
24570 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24571 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>';
24572 document.body.appendChild(m);
24573 var g=document.getElementById('scheme-grid');
24574 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);});
24575 var cl=document.getElementById('settings-close');
24576 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);
24577 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');});
24578 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24579 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24580 }
24581 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24582 }());
24583 </script>
24584 <footer class="site-footer">
24585 local code analysis - metrics, history and reports
24586 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
24587 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24588 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24589 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24590 · <a href="/api-docs" rel="noopener">REST API</a>
24591 </footer>
24592 {% if confluence_configured %}
24593 <script nonce="{{ csp_nonce }}">
24594 (function() {
24595 var postBtn = document.getElementById('postConfluenceBtn');
24596 var copyBtn = document.getElementById('copyWikiBtn');
24597 var modal = document.getElementById('confluenceModal');
24598 if (!postBtn || !modal) return;
24599
24600 postBtn.addEventListener('click', function() {
24601 document.getElementById('confStatus').style.display = 'none';
24602 modal.style.display = 'flex';
24603 });
24604 document.getElementById('confCancelBtn').addEventListener('click', function() {
24605 modal.style.display = 'none';
24606 });
24607 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24608
24609 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
24610 var btn = this;
24611 btn.disabled = true;
24612 var status = document.getElementById('confStatus');
24613 status.style.display = 'block';
24614 status.style.background = '#dbeafe';
24615 status.style.color = '#1e40af';
24616 status.textContent = 'Posting to Confluence\u2026';
24617 var resp = await fetch('/api/confluence/post', {
24618 method: 'POST',
24619 headers: { 'Content-Type': 'application/json' },
24620 body: JSON.stringify({
24621 run_id: '{{ run_id }}',
24622 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
24623 report_url: document.getElementById('confReportUrl').value.trim() || null
24624 })
24625 });
24626 var data = await resp.json();
24627 if (data.ok) {
24628 status.style.background = '#dcfce7'; status.style.color = '#166534';
24629 status.textContent = 'Posted! Page ID: ' + data.page_id;
24630 } else {
24631 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24632 status.textContent = 'Error: ' + (data.error || 'Unknown error');
24633 }
24634 btn.disabled = false;
24635 });
24636
24637 if (copyBtn) {
24638 copyBtn.addEventListener('click', async function() {
24639 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
24640 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
24641 var text = await resp.text();
24642 try {
24643 await navigator.clipboard.writeText(text);
24644 var orig = copyBtn.textContent;
24645 copyBtn.textContent = 'Copied!';
24646 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
24647 } catch(e) {
24648 alert('Clipboard write failed \u2014 check browser permissions.');
24649 }
24650 });
24651 }
24652 })();
24653 </script>
24654 {% endif %}
24655 <script nonce="{{ csp_nonce }}">
24656 (function() {
24657 var deleteBtn = document.getElementById('delete-run-btn');
24658 var modal = document.getElementById('delete-run-modal');
24659 var cancelBtn = document.getElementById('delete-run-cancel');
24660 var confirmBtn= document.getElementById('delete-run-confirm');
24661 if (!deleteBtn || !modal) return;
24662 deleteBtn.addEventListener('click', function() {
24663 document.getElementById('delete-run-status').style.display = 'none';
24664 modal.style.display = 'flex';
24665 });
24666 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
24667 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24668 confirmBtn.addEventListener('click', async function() {
24669 confirmBtn.disabled = true;
24670 cancelBtn.disabled = true;
24671 var status = document.getElementById('delete-run-status');
24672 status.style.display = 'block';
24673 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
24674 status.textContent = 'Deleting\u2026';
24675 try {
24676 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
24677 if (resp.status === 204 || resp.ok) {
24678 status.style.background = '#dcfce7'; status.style.color = '#166534';
24679 status.textContent = 'Deleted. Redirecting\u2026';
24680 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
24681 } else {
24682 var d = await resp.json().catch(function(){return {};});
24683 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24684 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
24685 confirmBtn.disabled = false;
24686 cancelBtn.disabled = false;
24687 }
24688 } catch (e) {
24689 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24690 status.textContent = 'Network error: ' + String(e);
24691 confirmBtn.disabled = false;
24692 cancelBtn.disabled = false;
24693 }
24694 });
24695 })();
24696 </script>
24697 <script nonce="{{ csp_nonce }}">(function(){
24698 var bundleBtn = document.getElementById('download-bundle-btn');
24699 if (bundleBtn) {
24700 bundleBtn.addEventListener('click', function() {
24701 bundleBtn.disabled = true;
24702 var orig = bundleBtn.textContent;
24703 bundleBtn.textContent = 'Preparing\u2026';
24704 fetch('/api/runs/{{ run_id }}/bundle')
24705 .then(function(r) {
24706 if (!r.ok) throw new Error('HTTP ' + r.status);
24707 return r.blob();
24708 })
24709 .then(function(blob) {
24710 var url = URL.createObjectURL(blob);
24711 var a = document.createElement('a');
24712 a.href = url;
24713 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
24714 document.body.appendChild(a);
24715 a.click();
24716 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
24717 bundleBtn.disabled = false;
24718 bundleBtn.textContent = orig;
24719 })
24720 .catch(function(e) {
24721 bundleBtn.disabled = false;
24722 bundleBtn.textContent = orig;
24723 alert('Bundle download failed: ' + String(e));
24724 });
24725 });
24726 }
24727 })();</script>
24728 <script nonce="{{ csp_nonce }}">(function(){
24729 var dot=document.getElementById('status-dot');
24730 var pingEl=document.getElementById('server-ping-ms');
24731 var tipEl=document.getElementById('server-tip-ping');
24732 var fm=document.getElementById('footer-mode');
24733 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)';}}
24734 function doPing(){
24735 var t0=performance.now();
24736 fetch('/healthz',{cache:'no-store'})
24737 .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);})
24738 .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)';}});
24739 }
24740 doPing();
24741 setInterval(doPing,5000);
24742 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
24743 })();</script>
24744 <script nonce="{{ csp_nonce }}">(function(){var s=document.querySelector('.summary-strip-hero');if(!s)return;var pad=s.querySelector('.stat-chip-pad');var real=Array.prototype.slice.call(s.querySelectorAll('.stat-chip')).filter(function(el){return el!==pad;});if(!real.length)return;function upd(){var n=real.length;if(pad){if(n%2===1){pad.style.display='';n++;}else{pad.style.display='none';}}var perRow=window.innerWidth<=640?2:Math.ceil(n/2);s.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';}upd();window.addEventListener('resize',upd);})();</script>
24745 {% if let Some(banner) = report_header_footer %}
24746 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
24747 {% endif %}
24748</body>
24749</html>
24750"##,
24751 ext = "html"
24752)]
24753#[allow(clippy::struct_excessive_bools)]
24755struct ResultTemplate {
24756 version: &'static str,
24757 report_title: String,
24758 project_path: String,
24759 output_dir: String,
24760 run_id: String,
24761 files_analyzed: u64,
24762 files_skipped: u64,
24763 physical_lines: u64,
24764 code_lines: u64,
24765 comment_lines: u64,
24766 blank_lines: u64,
24767 mixed_lines: u64,
24768 functions: u64,
24769 classes: u64,
24770 variables: u64,
24771 imports: u64,
24772 html_url: Option<String>,
24773 pdf_url: Option<String>,
24774 json_url: Option<String>,
24775 html_download_url: Option<String>,
24776 pdf_download_url: Option<String>,
24777 json_download_url: Option<String>,
24778 html_path: Option<String>,
24779 json_path: Option<String>,
24780 prev_run_id: Option<String>,
24781 prev_run_timestamp: Option<String>,
24782 prev_run_code_lines: Option<u64>,
24783 prev_fa_str: String,
24785 prev_fs_str: String,
24786 prev_pl_str: String,
24787 prev_cl_str: String,
24788 prev_cml_str: String,
24789 prev_bl_str: String,
24790 delta_fa_str: String,
24792 delta_fa_class: String,
24793 delta_fs_str: String,
24794 delta_fs_class: String,
24795 delta_pl_str: String,
24796 delta_pl_class: String,
24797 delta_cl_str: String,
24798 delta_cl_class: String,
24799 delta_cml_str: String,
24800 delta_cml_class: String,
24801 delta_bl_str: String,
24802 delta_bl_class: String,
24803 delta_lines_added: Option<i64>,
24805 delta_lines_removed: Option<i64>,
24806 delta_lines_net_str: String,
24807 delta_lines_net_class: String,
24808 delta_files_added: Option<usize>,
24809 delta_files_removed: Option<usize>,
24810 delta_files_modified: Option<usize>,
24811 delta_files_unchanged: Option<usize>,
24812 delta_unmodified_lines: Option<u64>,
24813 git_branch: Option<String>,
24815 git_branch_url: Option<String>,
24816 git_commit: Option<String>,
24817 git_commit_long: Option<String>,
24818 git_author: Option<String>,
24819 git_commit_url: Option<String>,
24820 scan_performed_by: String,
24822 scan_time_display: String,
24823 os_display: String,
24824 test_count: u64,
24825 test_assertion_count: u64,
24827 prev_scan_count: usize,
24829 current_scan_number: usize,
24830 submodule_rows: Vec<SubmoduleRow>,
24832 scan_config_url: String,
24833 lang_chart_json: String,
24834 #[allow(dead_code)]
24836 scatter_chart_json: String,
24837 #[allow(dead_code)]
24838 semantic_chart_json: String,
24839 #[allow(dead_code)]
24840 submodule_chart_json: String,
24841 #[allow(dead_code)]
24842 has_submodule_data: bool,
24843 #[allow(dead_code)]
24844 has_semantic_data: bool,
24845 pdf_generating: bool,
24846 csp_nonce: String,
24847 confluence_configured: bool,
24849 server_mode: bool,
24850 report_header_footer: Option<String>,
24852 run_id_short: String,
24853 #[allow(dead_code)]
24855 is_offline: bool,
24856 cyclomatic_complexity: u64,
24858 lsloc: Option<u64>,
24860 uloc: u64,
24862 dryness_pct_str: String,
24864 duplicate_group_count: usize,
24866 has_cocomo: bool,
24868 cocomo_effort_str: String,
24870 cocomo_duration_str: String,
24872 cocomo_staff_str: String,
24874 cocomo_ksloc_str: String,
24876 cocomo_mode_label: String,
24878 cocomo_mode_tooltip: String,
24880 complexity_alert: u32,
24882 has_coverage_data: bool,
24884 cov_line_pct: String,
24886 cov_fn_pct: String,
24888 cov_branch_pct: String,
24890 cov_lines_summary: String,
24892}
24893
24894#[derive(Template)]
24895#[template(
24896 source = r##"
24897<!doctype html>
24898<html lang="en">
24899<head>
24900 <meta charset="utf-8">
24901 <meta name="viewport" content="width=device-width, initial-scale=1">
24902 <title>OxideSLOC | Analyzing…</title>
24903 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24904 <style nonce="{{ csp_nonce }}">
24905 :root {
24906 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
24907 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24908 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
24909 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24910 }
24911 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24912 *{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;}
24913 .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);}
24914 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24915 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
24916 .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));}
24917 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24918 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
24919 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
24920 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24921 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24922 @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; } }
24923 .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;}
24924 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24925 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24926 .page-body{padding:32px 24px 36px;}
24927 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
24928 .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;}
24929 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
24930 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
24931 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
24932 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
24933 .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;}
24934 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
24935 .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;}
24936 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
24937 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
24938 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
24939 .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;}
24940 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
24941 .hidden{display:none!important;}
24942 .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;}
24943 .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;}
24944 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
24945 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
24946 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
24947 .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);}
24948 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
24949 .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;}
24950 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
24951 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24952 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24953 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
24954 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24955 .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;}
24956 @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));}}
24957 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24958 .site-footer a{color:var(--muted);}
24959 .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;}
24960 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
24961 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
24962 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
24963 </style>
24964</head>
24965<body>
24966 <div class="background-watermarks" aria-hidden="true">
24967 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24968 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24969 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24970 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24971 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24972 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24973 </div>
24974 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24975 <nav class="top-nav">
24976 <div class="top-nav-inner">
24977 <a href="/" class="brand">
24978 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
24979 <div class="brand-copy">
24980 <h1 class="brand-title">OxideSLOC</h1>
24981 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
24982 </div>
24983 </a>
24984 <div class="nav-right">
24985 <a class="nav-pill" href="/">Home</a>
24986 <div class="nav-dropdown">
24987 <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>
24988 <div class="nav-dropdown-menu">
24989 <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>
24990 </div>
24991 </div>
24992 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24993 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24994 <div class="nav-dropdown">
24995 <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>
24996 <div class="nav-dropdown-menu">
24997 <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>
24998 </div>
24999 </div>
25000 <div class="server-status-wrap" id="server-status-wrap">
25001 <div class="nav-pill server-online-pill" id="server-status-pill">
25002 <span class="status-dot" id="status-dot"></span>
25003 <span id="server-status-label">Server</span>
25004 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25005 </div>
25006 <div class="server-status-tip">
25007 OxideSLOC is running — accessible on your network.
25008 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25009 </div>
25010 </div>
25011 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25012 <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>
25013 </button>
25014 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25015 <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>
25016 <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>
25017 </button>
25018 </div>
25019 </div>
25020 </nav>
25021 <div class="page-body">
25022 <div class="wait-panel">
25023 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25024 <h2 class="wait-title">Analyzing your project…</h2>
25025 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25026 <div class="path-block">{{ project_path }}</div>
25027 <div class="metrics-row">
25028 <div class="metric-card">
25029 <div class="metric-label">Elapsed</div>
25030 <div class="metric-value" id="elapsed">0s</div>
25031 </div>
25032 <div class="metric-card">
25033 <div class="metric-label">Phase</div>
25034 <div class="metric-value" id="phase">Starting</div>
25035 </div>
25036 <div class="metric-card hidden" id="files-card">
25037 <div class="metric-label">Files</div>
25038 <div class="metric-value" id="files-progress">0</div>
25039 </div>
25040 </div>
25041 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25042 <div class="warn-slow hidden" id="warn-slow">
25043 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.
25044 </div>
25045 <div class="err-panel hidden" id="err-panel">
25046 <strong>Analysis failed</strong>
25047 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25048 </div>
25049 <div class="actions hidden" id="actions">
25050 <a href="/scan" class="btn-primary">Try Again</a>
25051 <a href="/view-reports" class="btn-outline">View Reports</a>
25052 </div>
25053 </div>
25054 </div>
25055 <script nonce="{{ csp_nonce }}">
25056 (function() {
25057 var WAIT_ID = {{ wait_id_json|safe }};
25058 var startTime = Date.now();
25059 var pollInterval = 1500;
25060 var retries = 0;
25061 var maxRetries = 5;
25062 var warnShown = false;
25063
25064 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();}
25065
25066 function elapsed() {
25067 return Math.floor((Date.now() - startTime) / 1000);
25068 }
25069
25070 function updateElapsed() {
25071 var s = elapsed();
25072 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25073 }
25074
25075 function setPhase(txt) {
25076 document.getElementById('phase').textContent = txt;
25077 }
25078
25079 var elapsedTimer = setInterval(updateElapsed, 1000);
25080
25081 function poll() {
25082 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25083 .then(function(r) {
25084 if (!r.ok) throw new Error('HTTP ' + r.status);
25085 return r.json();
25086 })
25087 .then(function(data) {
25088 retries = 0;
25089 if (data.state === 'complete') {
25090 clearInterval(elapsedTimer);
25091 setPhase('Done');
25092 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25093 } else if (data.state === 'failed') {
25094 clearInterval(elapsedTimer);
25095 setPhase('Failed');
25096 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25097 document.getElementById('err-panel').classList.remove('hidden');
25098 document.getElementById('actions').classList.remove('hidden');
25099 } else {
25100 // still running
25101 var s = elapsed();
25102 if (s > 90 && !warnShown) {
25103 warnShown = true;
25104 document.getElementById('warn-slow').classList.remove('hidden');
25105 }
25106 setPhase(data.phase || 'Running');
25107 var fd = data.files_done || 0, ft = data.files_total || 0;
25108 if (ft > 0) {
25109 var card = document.getElementById('files-card');
25110 if (card) card.classList.remove('hidden');
25111 var fp = document.getElementById('files-progress');
25112 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25113 }
25114 setTimeout(poll, pollInterval);
25115 }
25116 })
25117 .catch(function(err) {
25118 retries++;
25119 if (retries >= maxRetries) {
25120 clearInterval(elapsedTimer);
25121 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25122 document.getElementById('err-panel').classList.remove('hidden');
25123 document.getElementById('actions').classList.remove('hidden');
25124 } else {
25125 // exponential back-off capped at 8s
25126 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25127 }
25128 });
25129 }
25130
25131 setTimeout(poll, pollInterval);
25132
25133 // If the browser restores this page from bfcache (Back after viewing results),
25134 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25135 window.addEventListener("pageshow", function(e) {
25136 if (e.persisted) { setTimeout(poll, 200); }
25137 });
25138 })();
25139 </script>
25140 <footer class="site-footer">
25141 local code analysis - metrics, history and reports
25142 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25143 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25144 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25145 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25146 · <a href="/api-docs" rel="noopener">REST API</a>
25147 </footer>
25148 <script nonce="{{ csp_nonce }}">
25149 (function(){
25150 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25151 if(s==="dark")b.classList.add("dark-theme");
25152 var tt=document.getElementById("theme-toggle");
25153 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25154 })();
25155 (function spawnCodeParticles(){
25156 var c=document.getElementById('code-particles');if(!c)return;
25157 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'];
25158 for(var i=0;i<32;i++){(function(idx){
25159 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25160 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25161 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25162 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25163 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25164 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25165 c.appendChild(el);
25166 })(i);}
25167 })();
25168 (function randomizeWatermarks(){
25169 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25170 var placed=[];
25171 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;}
25172 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];}
25173 var half=Math.floor(wms.length/2);
25174 wms.forEach(function(img,i){
25175 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25176 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25177 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25178 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25179 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25180 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25181 });
25182 })();
25183 </script>
25184 <script nonce="{{ csp_nonce }}">
25185 (function(){
25186 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'}];
25187 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);});}
25188 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25189 function init(){
25190 var btn=document.getElementById('settings-btn');if(!btn)return;
25191 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25192 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>';
25193 document.body.appendChild(m);
25194 var g=document.getElementById('scheme-grid');
25195 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);});
25196 var cl=document.getElementById('settings-close');
25197 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);
25198 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');});
25199 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25200 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25201 }
25202 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25203 }());
25204 </script>
25205 <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]';
25206 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;}
25207 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>
25208</body>
25209</html>
25210"##,
25211 ext = "html"
25212)]
25213struct ScanWaitTemplate {
25214 version: &'static str,
25215 wait_id_json: String,
25216 project_path: String,
25217 csp_nonce: String,
25218}
25219
25220#[derive(Template)]
25221#[template(
25222 source = r##"
25223<!doctype html>
25224<html lang="en">
25225<head>
25226 <meta charset="utf-8">
25227 <meta name="viewport" content="width=device-width, initial-scale=1">
25228 <title>OxideSLOC | Error</title>
25229 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25230 <style nonce="{{ csp_nonce }}">
25231 :root {
25232 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25233 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25234 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25235 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25236 }
25237 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25238 *{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;}
25239 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25240 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25241 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25242 .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);}
25243 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25244 .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));}
25245 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25246 .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;}
25247 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25248 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25249 @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; } }
25250 .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;}
25251 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25252 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25253 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25254 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25255 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25256 .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;}
25257 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25258 .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);}
25259 .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;}
25260 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25261 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25262 .settings-modal-body{padding:14px 16px 16px;}
25263 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25264 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25265 .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;}
25266 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25267 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25268 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25269 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25270 .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;}
25271 .tz-select:focus{border-color:var(--oxide);}
25272 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25273 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25274 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25275 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25276 .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;}
25277 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25278 .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);}
25279 .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;}
25280 .btn-secondary:hover{background:var(--line);}
25281 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25282 .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;}
25283 .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;}
25284 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25285 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25286 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25287 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25288 .bug-report-panel.open{display:flex;}
25289 .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;}
25290 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25291 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25292 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25293 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25294 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25295 .br-network-badge.online .br-net-dot{background:#2a6846;}
25296 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25297 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25298 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25299 .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;}
25300 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25301 .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;}
25302 .btn-sm:hover{background:var(--line);}
25303 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25304 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25305 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25306 .bug-report-hint a:hover{text-decoration:underline;}
25307 .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;}
25308 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25309 .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;}
25310 .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;}
25311 .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;}
25312 @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));}}
25313 .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;}
25314 </style>
25315</head>
25316<body>
25317 <div class="background-watermarks" aria-hidden="true">
25318 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25319 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25320 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25321 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25322 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25323 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25324 </div>
25325 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25326 <div class="top-nav">
25327 <div class="top-nav-inner">
25328 <a class="brand" href="/">
25329 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25330 <div class="brand-copy">
25331 <div class="brand-title">OxideSLOC</div>
25332 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25333 </div>
25334 </a>
25335 <div class="nav-right">
25336 <a class="nav-pill" href="/">Home</a>
25337 <div class="nav-dropdown">
25338 <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>
25339 <div class="nav-dropdown-menu">
25340 <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>
25341 </div>
25342 </div>
25343 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25344 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25345 <div class="nav-dropdown">
25346 <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>
25347 <div class="nav-dropdown-menu">
25348 <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>
25349 </div>
25350 </div>
25351 <div class="server-status-wrap" id="server-status-wrap">
25352 <div class="nav-pill server-online-pill" id="server-status-pill">
25353 <span class="status-dot" id="status-dot"></span>
25354 <span id="server-status-label">Server</span>
25355 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25356 </div>
25357 <div class="server-status-tip">
25358 OxideSLOC is running — accessible on your network.
25359 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25360 </div>
25361 </div>
25362 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25363 <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>
25364 </button>
25365 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25366 <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>
25367 <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>
25368 </button>
25369 </div>
25370 </div>
25371 </div>
25372
25373 <div class="page">
25374 <div class="panel">
25375 <h1>Error</h1>
25376 <div class="error-box" id="error-msg-text">{{ message }}</div>
25377 <div id="br-meta" hidden
25378 data-version="{{ version }}"
25379 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25380 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25381 <div class="actions">
25382 <a class="btn-primary" href="/scan">Back to setup</a>
25383 {% if let Some(report_url) = last_report_url %}
25384 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25385 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25386 {% else %}
25387 <a class="btn-secondary" href="/view-reports">View Reports</a>
25388 {% endif %}
25389 </div>
25390 <div class="bug-report-section" id="bug-report-section">
25391 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25392 <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>
25393 Generate Bug Report
25394 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25395 </button>
25396 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25397 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
25398 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
25399 <div class="bug-report-btns">
25400 <button type="button" class="btn-sm" id="bug-report-copy">
25401 <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>
25402 Copy to clipboard
25403 </button>
25404 <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;">
25405 <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>
25406 Open GitHub Issue
25407 </a>
25408 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25409 <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>
25410 Save as file
25411 </button>
25412 </div>
25413 <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>
25414 <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>
25415 </div>
25416 </div>
25417 </div>
25418 </div>
25419 <footer class="site-footer">
25420 oxide-sloc v{{ version }} — local code metrics workbench ·
25421 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25422 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25423 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25424 · <a href="/api-docs" rel="noopener">REST API</a>
25425 </footer>
25426 <script nonce="{{ csp_nonce }}">(function(){
25427 var meta=document.getElementById('br-meta');
25428 var pre=document.getElementById('bug-report-pre');
25429 var copyBtn=document.getElementById('bug-report-copy');
25430 var trigger=document.getElementById('bug-report-trigger');
25431 var panel=document.getElementById('bug-report-panel');
25432 var networkBadge=document.getElementById('br-network-badge');
25433 var networkLabel=document.getElementById('br-network-label');
25434 var ghLink=document.getElementById('bug-report-github-link');
25435 var saveBtn=document.getElementById('bug-report-save');
25436 var hintOnline=document.getElementById('br-hint-online');
25437 var hintOffline=document.getElementById('br-hint-offline');
25438 if(!meta||!pre)return;
25439 var ver=meta.getAttribute('data-version')||'';
25440 var runId=meta.getAttribute('data-run-id')||'';
25441 var code=meta.getAttribute('data-error-code')||'';
25442 var msgEl=document.getElementById('error-msg-text');
25443 var msg=msgEl?msgEl.textContent.trim():'';
25444 function getBrowser(){
25445 var ua=navigator.userAgent;
25446 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25447 if(!m)return 'Unknown browser';
25448 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25449 return n+' '+m[2];
25450 }
25451 var lines=['oxide-sloc Bug Report','==============================',''];
25452 lines.push('App version: v'+ver);
25453 if(code)lines.push('HTTP status: '+code);
25454 if(runId)lines.push('Run ID: '+runId);
25455 lines.push('Page: '+window.location.pathname+(window.location.search||''));
25456 lines.push('Timestamp: '+new Date().toISOString());
25457 lines.push('Browser: '+getBrowser());
25458 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
25459 lines.push('');
25460 lines.push('Error message:');
25461 lines.push(msg);
25462 lines.push('');
25463 lines.push('Steps to reproduce:');
25464 lines.push(' 1. ');
25465 lines.push('');
25466 lines.push('Expected behavior:');
25467 lines.push(' ');
25468 pre.textContent=lines.join('\n');
25469 function applyNetwork(online){
25470 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25471 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25472 if(ghLink){
25473 if(online){
25474 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25475 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25476 }
25477 ghLink.style.display=online?'inline-flex':'none';
25478 }
25479 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25480 if(hintOnline)hintOnline.style.display=online?'block':'none';
25481 if(hintOffline)hintOffline.style.display=online?'none':'block';
25482 }
25483 applyNetwork(navigator.onLine);
25484 var probed=false;
25485 function probeNetwork(){
25486 if(probed)return;probed=true;
25487 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25488 var probeIdx=0;
25489 function tryNext(){
25490 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25491 var u=probeUrls[probeIdx++];
25492 var c2=new AbortController();
25493 var t2=setTimeout(function(){c2.abort();},4000);
25494 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25495 .then(function(){clearTimeout(t2);applyNetwork(true);})
25496 .catch(function(){clearTimeout(t2);tryNext();});
25497 }
25498 tryNext();
25499 }
25500 if(trigger&&panel){
25501 trigger.addEventListener('click',function(){
25502 var open=panel.classList.toggle('open');
25503 trigger.classList.toggle('open',open);
25504 trigger.setAttribute('aria-expanded',open?'true':'false');
25505 if(open)probeNetwork();
25506 });
25507 }
25508 if(copyBtn){
25509 copyBtn.addEventListener('click',function(){
25510 var txt=pre.textContent;
25511 if(navigator.clipboard&&navigator.clipboard.writeText){
25512 navigator.clipboard.writeText(txt).then(function(){
25513 copyBtn.textContent='\u2713 Copied!';
25514 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);
25515 });
25516 }else{
25517 var ta=document.createElement('textarea');
25518 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25519 document.body.appendChild(ta);ta.select();
25520 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25521 document.body.removeChild(ta);
25522 }
25523 });
25524 }
25525 if(saveBtn){
25526 saveBtn.addEventListener('click',function(){
25527 var txt=pre.textContent;
25528 var blob=new Blob([txt],{type:'text/plain'});
25529 var url=URL.createObjectURL(blob);
25530 var a=document.createElement('a');
25531 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25532 document.body.appendChild(a);a.click();
25533 document.body.removeChild(a);URL.revokeObjectURL(url);
25534 });
25535 }
25536 })();</script>
25537 <script nonce="{{ csp_nonce }}">
25538 (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");});})();
25539 (function spawnCodeParticles() {
25540 var container = document.getElementById('code-particles');
25541 if (!container) return;
25542 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'];
25543 for (var i = 0; i < 38; i++) {
25544 (function(idx) {
25545 var el = document.createElement('span');
25546 el.className = 'code-particle';
25547 el.textContent = snippets[idx % snippets.length];
25548 var left = Math.random() * 94 + 2;
25549 var top = Math.random() * 88 + 6;
25550 var dur = (Math.random() * 10 + 9).toFixed(1);
25551 var delay = (Math.random() * 18).toFixed(1);
25552 var rot = (Math.random() * 26 - 13).toFixed(1);
25553 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25554 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';
25555 container.appendChild(el);
25556 })(i);
25557 }
25558 })();
25559 (function randomizeWatermarks() {
25560 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25561 var placed = [];
25562 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; }
25563 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]; }
25564 var half = Math.floor(wms.length/2);
25565 wms.forEach(function(img, i) {
25566 var pos = pick(i < half);
25567 var w = Math.floor(Math.random()*60+80);
25568 var rot = (Math.random()*40-20).toFixed(1);
25569 var op = (Math.random()*0.08+0.05).toFixed(2);
25570 var animDur = (Math.random()*6+5).toFixed(1);
25571 var animDelay = (Math.random()*10).toFixed(1);
25572 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';
25573 });
25574 })();
25575 </script>
25576 <script nonce="{{ csp_nonce }}">
25577 (function(){
25578 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'}];
25579 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);});}
25580 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25581 function init(){
25582 var btn=document.getElementById('settings-btn');if(!btn)return;
25583 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25584 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>';
25585 document.body.appendChild(m);
25586 var g=document.getElementById('scheme-grid');
25587 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);});
25588 var cl=document.getElementById('settings-close');
25589 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);
25590 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');});
25591 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25592 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25593 }
25594 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25595 }());
25596 </script>
25597 <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]';
25598 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;}
25599 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>
25600</body>
25601</html>
25602"##,
25603 ext = "html"
25604)]
25605struct ErrorTemplate {
25606 message: String,
25607 last_report_url: Option<String>,
25609 last_report_label: Option<String>,
25611 run_id: Option<String>,
25613 error_code: Option<u16>,
25615 csp_nonce: String,
25616 version: &'static str,
25617}
25618
25619#[derive(Template)]
25622#[template(
25623 source = r##"
25624<!doctype html>
25625<html lang="en">
25626<head>
25627 <meta charset="utf-8">
25628 <meta name="viewport" content="width=device-width, initial-scale=1">
25629 <title>OxideSLOC | Locate Report</title>
25630 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25631 <style nonce="{{ csp_nonce }}">
25632 :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);}
25633 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
25634 *{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;}
25635 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25636 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25637 .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);}
25638 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25639 .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));}
25640 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25641 .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;}
25642 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25643 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
25644 @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;}}
25645 .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;}
25646 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25647 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25648 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25649 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25650 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
25651 .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;}
25652 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25653 .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);}
25654 .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;}
25655 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25656 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25657 .settings-modal-body{padding:14px 16px 16px;}
25658 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25659 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25660 .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;}
25661 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25662 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25663 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25664 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25665 .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;}
25666 .tz-select:focus{border-color:var(--oxide);}
25667 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25668 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25669 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25670 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
25671 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
25672 .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;}
25673 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
25674 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
25675 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
25676 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
25677 .locate-row{display:flex;gap:8px;align-items:stretch;}
25678 .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;}
25679 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
25680 body.dark-theme .locate-input{background:var(--surface-2);}
25681 .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;}
25682 .warning-banner.show{display:flex;}
25683 .warning-banner svg{flex:0 0 auto;}
25684 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
25685 .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;}
25686 .error-inline.show{display:flex;}
25687 .error-inline svg{flex:0 0 auto;margin-top:2px;}
25688 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
25689 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
25690 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
25691 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
25692 .err-kv-p{margin:0 0 4px;}
25693 .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;}
25694 .success-inline.show{display:flex;}
25695 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
25696 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
25697 .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;}
25698 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
25699 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
25700 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
25701 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
25702 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
25703 .fh-row:last-child{border-bottom:none;}
25704 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
25705 .fh-dir{font-weight:800;color:var(--text);}
25706 .fh-hl{color:var(--oxide);font-weight:700;}
25707 .fh-muted{color:var(--muted);}
25708 .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;}
25709 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
25710 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
25711 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
25712 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
25713 .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;}
25714 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
25715 .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;}
25716 .btn-secondary:hover{background:var(--line);}
25717 .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;}
25718 .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;}
25719 .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;}
25720 @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));}}
25721 .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;}
25722 .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;}
25723 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25724 </style>
25725</head>
25726<body>
25727 <div class="background-watermarks" aria-hidden="true">
25728 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25729 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25730 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25731 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25732 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25733 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25734 </div>
25735 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25736 <div class="top-nav">
25737 <div class="top-nav-inner">
25738 <a class="brand" href="/">
25739 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25740 <div class="brand-copy">
25741 <div class="brand-title">OxideSLOC</div>
25742 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25743 </div>
25744 </a>
25745 <div class="nav-right">
25746 <a class="nav-pill" href="/">Home</a>
25747 <div class="nav-dropdown">
25748 <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>
25749 <div class="nav-dropdown-menu">
25750 <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>
25751 </div>
25752 </div>
25753 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25754 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25755 <div class="nav-dropdown">
25756 <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>
25757 <div class="nav-dropdown-menu">
25758 <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>
25759 </div>
25760 </div>
25761 <div class="server-status-wrap" id="server-status-wrap">
25762 <div class="nav-pill server-online-pill" id="server-status-pill">
25763 <span class="status-dot" id="status-dot"></span>
25764 <span id="server-status-label">Server</span>
25765 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25766 </div>
25767 <div class="server-status-tip">
25768 OxideSLOC is running — accessible on your network.
25769 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25770 </div>
25771 </div>
25772 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25773 <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>
25774 </button>
25775 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25776 <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>
25777 <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>
25778 </button>
25779 </div>
25780 </div>
25781 </div>
25782
25783 <div class="page">
25784 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
25785 <div class="panel">
25786 <h1>Report File Not Found</h1>
25787 <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>
25788 <div class="field-label">Missing file</div>
25789 <div class="filename-chip">
25790 <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>
25791 {{ expected_filename }}
25792 </div>
25793 <div class="locate-section">
25794 <h2>Locate Scan Output Folder</h2>
25795 <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>
25796 <p>OxideSLOC will find the correct files inside automatically.</p>
25797 <div class="locate-row">
25798 <input type="text" id="locate-file-input"
25799 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
25800 class="locate-input" autocomplete="off" spellcheck="false">
25801 {% if !server_mode %}
25802 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
25803 {% endif %}
25804 </div>
25805 <div class="warning-banner" id="filename-warning">
25806 <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>
25807 <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>
25808 </div>
25809 <div class="error-inline" id="locate-error">
25810 <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>
25811 <span id="locate-error-text"></span>
25812 </div>
25813 <div class="success-inline" id="locate-success">
25814 <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>
25815 <span>Scan restored — loading report…</span>
25816 </div>
25817 <div class="btn-row">
25818 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
25819 <a class="btn-secondary" href="/view-reports">View Reports</a>
25820 </div>
25821 <div class="folder-hint-shell">
25822 <div class="folder-hint-hdr">
25823 <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>
25824 Expected Folder Structure — Select the Top-Level Folder
25825 </div>
25826 <div class="folder-hint-body">
25827 <div class="fh-row">
25828 <span class="fh-tog">►</span>
25829 <span class="fh-dir">project_20260601-0029-…/</span>
25830 <span class="fh-badge">← select this</span>
25831 </div>
25832 <div class="fh-row fh-i1">
25833 <span class="fh-tog">►</span>
25834 <span class="fh-dir">html/</span>
25835 </div>
25836 <div class="fh-row fh-i2">
25837 <span class="fh-bul">•</span>
25838 <span class="fh-hl">{{ expected_filename }}</span>
25839 </div>
25840 <div class="fh-row fh-i1">
25841 <span class="fh-tog">►</span>
25842 <span class="fh-dir">json/</span>
25843 </div>
25844 <div class="fh-row fh-i2">
25845 <span class="fh-bul">•</span>
25846 <span class="fh-muted">result_*.json</span>
25847 </div>
25848 <div class="fh-row fh-i1">
25849 <span class="fh-tog">►</span>
25850 <span class="fh-dir">pdf/</span>
25851 </div>
25852 <div class="fh-row fh-i2">
25853 <span class="fh-bul">•</span>
25854 <span class="fh-muted">report_*.pdf</span>
25855 </div>
25856 <div class="fh-row fh-i1">
25857 <span class="fh-tog">►</span>
25858 <span class="fh-dir">excel/</span>
25859 </div>
25860 <div class="fh-row fh-i2">
25861 <span class="fh-bul">•</span>
25862 <span class="fh-muted">report_*.csv report_*.xlsx</span>
25863 </div>
25864 </div>
25865 </div>
25866 </div>
25867 </div>
25868 </div>
25869 <footer class="site-footer">
25870 oxide-sloc v{{ version }} — local code metrics workbench ·
25871 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25872 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25873 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25874 · <a href="/api-docs" rel="noopener">REST API</a>
25875 </footer>
25876 <script nonce="{{ csp_nonce }}">(function(){
25877 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25878 if(s==="dark")b.classList.add("dark-theme");
25879 document.getElementById("theme-toggle").addEventListener("click",function(){
25880 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
25881 });
25882 })();</script>
25883 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
25884 var c=document.getElementById('code-particles');if(!c)return;
25885 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'];
25886 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);}
25887 })();
25888 (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>
25889 <script nonce="{{ csp_nonce }}">(function(){
25890 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'}];
25891 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);});}
25892 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25893 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');});}
25894 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25895 }());</script>
25896 <script nonce="{{ csp_nonce }}">(function(){
25897 var meta=document.getElementById('locate-meta');
25898 var inp=document.getElementById('locate-file-input');
25899 var browseBtn=document.getElementById('browse-locate-btn');
25900 var submitBtn=document.getElementById('locate-submit-btn');
25901 var warning=document.getElementById('filename-warning');
25902 var errBox=document.getElementById('locate-error');
25903 var errText=document.getElementById('locate-error-text');
25904 var okBox=document.getElementById('locate-success');
25905 var expected=meta?meta.getAttribute('data-expected'):'';
25906 var runId=meta?meta.getAttribute('data-run-id'):'';
25907 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
25908 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
25909 function showErr(msg){
25910 if(errText){
25911 errText.innerHTML='';
25912 var lines=msg.split('\n');
25913 var hasPairs=lines.some(function(l){return / : /.test(l);});
25914 if(!hasPairs){errText.textContent=msg;}
25915 else{
25916 var frag=document.createDocumentFragment();var tbl=null;
25917 lines.forEach(function(line){
25918 var m=line.match(/^(.*?) : (.*)$/);
25919 if(m){
25920 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
25921 var tr=document.createElement('tr');
25922 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
25923 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
25924 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
25925 } else {
25926 tbl=null;
25927 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
25928 }
25929 });
25930 errText.appendChild(frag);
25931 }
25932 }
25933 if(errBox)errBox.classList.add('show');
25934 if(okBox)okBox.classList.remove('show');
25935 }
25936 function clearErr(){
25937 if(errBox)errBox.classList.remove('show');
25938 if(okBox)okBox.classList.remove('show');
25939 }
25940 function validate(){
25941 var val=inp?inp.value.trim():'';
25942 clearErr();
25943 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
25944 if(submitBtn)submitBtn.disabled=false;
25945 if(warning){
25946 var name=basename(val);
25947 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
25948 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
25949 else warning.classList.remove('show');
25950 }
25951 }
25952 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
25953 if(browseBtn){
25954 browseBtn.addEventListener('click',function(){
25955 browseBtn.disabled=true;browseBtn.textContent='...';
25956 fetch('/pick-directory')
25957 .then(function(r){return r.ok?r.json():{cancelled:true};})
25958 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
25959 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
25960 });
25961 }
25962 if(submitBtn){
25963 submitBtn.addEventListener('click',function(){
25964 var folder=inp?inp.value.trim():'';
25965 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
25966 clearErr();
25967 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
25968 var body=new URLSearchParams();
25969 body.set('file_path',folder);
25970 body.set('redirect_url',redirectUrl);
25971 body.set('expected_run_id',runId);
25972 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
25973 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
25974 .then(function(d){
25975 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
25976 if(d&&d.ok){
25977 if(okBox)okBox.classList.add('show');
25978 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
25979 } else {
25980 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
25981 }
25982 })
25983 .catch(function(e){
25984 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
25985 showErr('Network error: '+String(e));
25986 });
25987 });
25988 }
25989 })();</script>
25990 <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>
25991</body>
25992</html>
25993"##,
25994 ext = "html"
25995)]
25996struct LocateFileTemplate {
25997 run_id: String,
25998 artifact_type: String,
25999 expected_filename: String,
26000 server_mode: bool,
26001 csp_nonce: String,
26002 version: &'static str,
26003}
26004
26005#[derive(Template)]
26008#[template(
26009 source = r##"
26010<!doctype html>
26011<html lang="en">
26012<head>
26013 <meta charset="utf-8">
26014 <meta name="viewport" content="width=device-width, initial-scale=1">
26015 <title>OxideSLOC | Locate Scan Files</title>
26016 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26017 <style nonce="{{ csp_nonce }}">
26018 :root {
26019 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26020 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26021 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26022 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26023 }
26024 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26025 *{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;}
26026 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26027 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26028 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26029 .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);}
26030 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26031 .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));}
26032 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26033 .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;}
26034 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26035 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26036 @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;}}
26037 .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;}
26038 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26039 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26040 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26041 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26042 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26043 .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;}
26044 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26045 .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);}
26046 .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;}
26047 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26048 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26049 .settings-modal-body{padding:14px 16px 16px;}
26050 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26051 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26052 .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;}
26053 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26054 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26055 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26056 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26057 .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;}
26058 .tz-select:focus{border-color:var(--oxide);}
26059 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26060 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26061 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26062 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26063 .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;}
26064 .error-box.hidden{display:none;}
26065 .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;}
26066 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26067 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26068 .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;}
26069 .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;}
26070 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26071 .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;}
26072 .btn-secondary:hover{background:var(--line);}
26073 .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;}
26074 .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;}
26075 .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;}
26076 @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));}}
26077 .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;}
26078 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26079 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26080 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26081 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26082 .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;}
26083 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26084 body.dark-theme .relocate-input{background:var(--surface-2);}
26085 </style>
26086</head>
26087<body>
26088 <div class="background-watermarks" aria-hidden="true">
26089 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26090 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26091 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26092 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26093 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26094 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26095 </div>
26096 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26097 <div class="top-nav">
26098 <div class="top-nav-inner">
26099 <a class="brand" href="/">
26100 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26101 <div class="brand-copy">
26102 <div class="brand-title">OxideSLOC</div>
26103 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26104 </div>
26105 </a>
26106 <div class="nav-right">
26107 <a class="nav-pill" href="/">Home</a>
26108 <div class="nav-dropdown">
26109 <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>
26110 <div class="nav-dropdown-menu">
26111 <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>
26112 </div>
26113 </div>
26114 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26115 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26116 <div class="nav-dropdown">
26117 <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>
26118 <div class="nav-dropdown-menu">
26119 <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>
26120 </div>
26121 </div>
26122 <div class="server-status-wrap" id="server-status-wrap">
26123 <div class="nav-pill server-online-pill" id="server-status-pill">
26124 <span class="status-dot" id="status-dot"></span>
26125 <span id="server-status-label">Server</span>
26126 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26127 </div>
26128 <div class="server-status-tip">
26129 OxideSLOC is running — accessible on your network.
26130 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26131 </div>
26132 </div>
26133 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26134 <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>
26135 </button>
26136 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26137 <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>
26138 <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>
26139 </button>
26140 </div>
26141 </div>
26142 </div>
26143
26144 <div class="page">
26145 <div class="panel">
26146 <h1>Scan Files Moved</h1>
26147 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26148 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26149 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26150 <div class="relocate-section">
26151 <h2>Locate Scan Output</h2>
26152 <p>Select the <strong>top-level</strong> scan output folder (the one named <code>project_YYYYMMDD-HHMM-…</code>). Result files will be found inside it automatically — do not navigate into a subfolder.</p>
26153 <div class="relocate-row">
26154 <input type="text" id="relocate-folder" name="folder_path"
26155 value="{{ folder_hint }}"
26156 placeholder="Path to folder containing scan output..."
26157 class="relocate-input" autocomplete="off" spellcheck="false">
26158 {% if !server_mode %}
26159 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
26160 {% endif %}
26161 </div>
26162 <div style="margin-top:12px;">
26163 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26164 </div>
26165 </div>
26166 <div class="actions">
26167 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26168 <a class="btn-secondary" href="/view-reports">View Reports</a>
26169 </div>
26170 </div>
26171 </div>
26172 <footer class="site-footer">
26173 oxide-sloc v{{ version }} — local code metrics workbench ·
26174 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26175 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26176 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26177 · <a href="/api-docs" rel="noopener">REST API</a>
26178 </footer>
26179 <script nonce="{{ csp_nonce }}">
26180 (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");});})();
26181 (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);}})();
26182 (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;});})();
26183 </script>
26184 <script nonce="{{ csp_nonce }}">
26185 (function(){
26186 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'}];
26187 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);});}
26188 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26189 function init(){
26190 var btn=document.getElementById('settings-btn');if(!btn)return;
26191 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26192 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>';
26193 document.body.appendChild(m);
26194 var g=document.getElementById('scheme-grid');
26195 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);});
26196 var cl=document.getElementById('settings-close');
26197 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);
26198 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');});
26199 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26200 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26201 }
26202 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26203 }());
26204 (function(){
26205 var browseBtn=document.getElementById('browse-relocate-btn');
26206 if(browseBtn){
26207 browseBtn.addEventListener('click',function(){
26208 browseBtn.disabled=true;browseBtn.textContent='...';
26209 var inp=document.getElementById('relocate-folder');
26210 var hint=inp?inp.value:'';
26211 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
26212 .then(function(r){return r.ok?r.json():{cancelled:true};})
26213 .then(function(d){
26214 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26215 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26216 })
26217 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26218 });
26219 }
26220 var restoreBtn=document.getElementById('restore-btn');
26221 var errBox=document.getElementById('relocate-error-box');
26222 var okBox=document.getElementById('relocate-success-box');
26223 if(restoreBtn){
26224 restoreBtn.addEventListener('click',function(){
26225 var inp=document.getElementById('relocate-folder');
26226 var folder=inp?inp.value.trim():'';
26227 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26228 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26229 var body=new URLSearchParams();
26230 body.set('run_id','{{ run_id }}');
26231 body.set('redirect_url','{{ redirect_url }}');
26232 body.set('folder_path',folder);
26233 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26234 .then(function(r){return r.json();})
26235 .then(function(d){
26236 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26237 if(d&&d.ok){
26238 if(errBox)errBox.classList.add('hidden');
26239 if(okBox){okBox.style.display='block';}
26240 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26241 } else {
26242 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26243 }
26244 })
26245 .catch(function(e){
26246 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26247 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26248 });
26249 });
26250 }
26251 }());
26252 </script>
26253 <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]';
26254 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;}
26255 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>
26256</body>
26257</html>
26258"##,
26259 ext = "html"
26260)]
26261struct RelocateScanTemplate {
26262 message: String,
26263 run_id: String,
26264 folder_hint: String,
26265 redirect_url: String,
26266 server_mode: bool,
26267 csp_nonce: String,
26268 version: &'static str,
26269}
26270
26271#[derive(Template)]
26274#[template(
26275 source = r##"
26276<!doctype html>
26277<html lang="en">
26278<head>
26279 <meta charset="utf-8">
26280 <meta name="viewport" content="width=device-width, initial-scale=1">
26281 <title>OxideSLOC | View Reports</title>
26282 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26283 <style nonce="{{ csp_nonce }}">
26284 :root {
26285 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26286 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26287 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26288 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26289 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26290 }
26291 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; }
26292 *{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;}
26293 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26294 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26295 .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);}
26296 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26297 .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));}
26298 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26299 .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;}
26300 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26301 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26302 @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; } }
26303 .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;}
26304 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26305 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26306 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26307 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26308 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26309 .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;}
26310 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26311 .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);}
26312 .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;}
26313 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26314 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26315 .settings-modal-body{padding:14px 16px 16px;}
26316 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26317 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26318 .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;}
26319 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26320 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26321 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26322 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26323 .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;}
26324 .tz-select:focus{border-color:var(--oxide);}
26325 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26326 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26327 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26328 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26329 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26330 .panel-meta{font-size:13px;color:var(--muted);}
26331 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26332 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26333 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26334 .per-page-label{font-size:13px;color:var(--muted);}
26335 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;}
26336 .filter-input{min-width:180px;cursor:text;}
26337 .table-wrap{width:100%;overflow-x:auto;}
26338 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26339 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;}
26340 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26341 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26342 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26343 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26344 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26345 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26346 tr:last-child td{border-bottom:none;}
26347 tr:hover td{background:var(--surface-2);}
26348 .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);}
26349 .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);}
26350 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26351 .metric-num{font-weight:700;color:var(--text);}
26352 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26353 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26354 .git-commit-chip{cursor:help;}
26355 .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
26356 .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;}
26357 .btn:hover{background:var(--line);}
26358 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26359 .btn.primary:hover{opacity:.9;}
26360 .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;}
26361 .btn-back:hover{background:var(--line);}
26362 .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;}
26363 .export-btn:hover{background:var(--line);}
26364 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26365 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26366 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26367 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26368 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26369 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26370 .pagination-info{font-size:13px;color:var(--muted);}
26371 .pagination-btns{display:flex;gap:6px;}
26372 .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;}
26373 .pg-btn:hover:not(:disabled){background:var(--line);}
26374 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26375 .pg-btn:disabled{opacity:.35;cursor:default;}
26376 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26377 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26378 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
26379 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26380 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26381 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26382 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
26383 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26384 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26385 .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;}
26386 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26387 .site-footer a{color:var(--muted);}
26388 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26389 .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%;}
26390 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26391 .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;}
26392 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26393 .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;}
26394 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26395 .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;}
26396 .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;}
26397 .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;}
26398 @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));}}
26399 .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;}
26400 .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;}
26401 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26402 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26403 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26404 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26405 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26406 .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;}
26407 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26408 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26409 .watched-chip-rm:hover{color:var(--oxide);}
26410 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26411 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26412 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26413 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26414 .rpt-btn{min-width:58px;justify-content:center;}
26415 .flex-row{display:flex;align-items:center;gap:8px;}
26416 .report-cell{overflow:visible;white-space:normal;}
26417 #history-table col:nth-child(1){width:185px;}
26418 #history-table col:nth-child(2){width:220px;}
26419 #history-table col:nth-child(3){width:100px;}
26420 #history-table col:nth-child(4){width:72px;}
26421 #history-table col:nth-child(5){width:82px;}
26422 #history-table col:nth-child(6){width:82px;}
26423 #history-table col:nth-child(7){width:65px;}
26424 #history-table col:nth-child(8){width:90px;}
26425 #history-table col:nth-child(9){width:85px;}
26426 #history-table col:nth-child(10){width:115px;}
26427 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26428 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26429 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26430 .submod-details summary::-webkit-details-marker{display:none;}
26431.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26432 .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;}
26433 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26434 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26435 </style>
26436</head>
26437<body>
26438 <div class="background-watermarks" aria-hidden="true">
26439 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26440 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26441 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26442 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26443 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26444 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26445 </div>
26446 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26447 <div class="top-nav">
26448 <div class="top-nav-inner">
26449 <a class="brand" href="/">
26450 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26451 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26452 </a>
26453 <div class="nav-right">
26454 <a class="nav-pill" href="/">Home</a>
26455 <div class="nav-dropdown">
26456 <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>
26457 <div class="nav-dropdown-menu">
26458 <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>
26459 </div>
26460 </div>
26461 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26462 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26463 <div class="nav-dropdown">
26464 <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>
26465 <div class="nav-dropdown-menu">
26466 <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>
26467 </div>
26468 </div>
26469 <div class="server-status-wrap" id="server-status-wrap">
26470 <div class="nav-pill server-online-pill" id="server-status-pill">
26471 <span class="status-dot" id="status-dot"></span>
26472 <span id="server-status-label">Server</span>
26473 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26474 </div>
26475 <div class="server-status-tip">
26476 OxideSLOC is running — accessible on your network.
26477 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26478 </div>
26479 </div>
26480 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26481 <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>
26482 </button>
26483 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26484 <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>
26485 <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>
26486 </button>
26487 </div>
26488 </div>
26489 </div>
26490
26491 <div class="page">
26492 {% if let Some(err) = browse_error %}
26493 <div class="toast-error">
26494 <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>
26495 {{ err }}
26496 </div>
26497 {% endif %}
26498 {% if linked_count > 0 %}
26499 <div class="toast-success">
26500 <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>
26501 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26502 </div>
26503 {% endif %}
26504 <div class="watched-bar">
26505 <div class="watched-bar-left">
26506 <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>
26507 <span class="watched-label">Watched Folders</span>
26508 <div class="watched-chips">
26509 {% if server_mode %}
26510 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26511 {% else %}
26512 {% for dir in watched_dirs %}
26513 <span class="watched-chip">
26514 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26515 <form method="POST" action="/watched-dirs/remove" style="display:contents">
26516 <input type="hidden" name="folder_path" value="{{ dir }}">
26517 <input type="hidden" name="redirect_to" value="/view-reports">
26518 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
26519 </form>
26520 </span>
26521 {% endfor %}
26522 {% if watched_dirs.is_empty() %}
26523 <span class="watched-none">No folders watched — click Choose to add one</span>
26524 {% endif %}
26525 {% endif %}
26526 </div>
26527 </div>
26528 {% if !server_mode %}
26529 <div class="watched-bar-right">
26530 <button type="button" class="btn" id="add-watched-btn">
26531 <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>
26532 Choose
26533 </button>
26534 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26535 <input type="hidden" name="redirect_to" value="/view-reports">
26536 <button type="submit" class="btn">↻ Refresh</button>
26537 </form>
26538 </div>
26539 {% endif %}
26540 </div>
26541 {% if total_scans > 0 %}
26542 <div class="summary-strip">
26543 <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>
26544 <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>
26545 <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>
26546 <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>
26547 </div>
26548 {% endif %}
26549
26550 <section class="panel">
26551 <div class="panel-header">
26552 <div>
26553 <h1>View Reports</h1>
26554 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
26555 {% 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 %}
26556 </div>
26557 <div class="flex-row">
26558 <button type="button" class="export-btn" id="export-csv-btn">
26559 <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>
26560 Export CSV
26561 </button>
26562 <button type="button" class="export-btn" id="export-xls-btn">
26563 <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>
26564 Export Excel
26565 </button>
26566 </div>
26567 </div>
26568
26569 {% if entries.is_empty() %}
26570 <div class="empty-state">
26571 <strong>No reports with viewable HTML yet</strong>
26572 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.
26573 </div>
26574 {% else %}
26575 <div class="filter-row">
26576 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
26577 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
26578 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
26579 </div>
26580 <div class="table-wrap">
26581 <table id="history-table">
26582 <colgroup>
26583 <col><col><col><col><col><col><col><col><col><col>
26584 </colgroup>
26585 <thead>
26586 <tr id="history-thead">
26587 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26588 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26589 <th>Run ID<div class="col-resize-handle"></div></th>
26590 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26591 <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>
26592 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26593 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26594 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26595 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26596 <th>Report<div class="col-resize-handle"></div></th>
26597 </tr>
26598 </thead>
26599 <tbody id="history-tbody">
26600 {% for entry in entries %}
26601 <tr class="history-row" data-run="{{ entry.run_id }}"
26602 data-timestamp="{{ entry.timestamp }}"
26603 data-project="{{ entry.project_label }}"
26604 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
26605 data-skipped="{{ entry.files_skipped }}"
26606 data-comments="{{ entry.comment_lines }}"
26607 data-blank="{{ entry.blank_lines }}"
26608 data-physical="{{ entry.total_physical_lines }}"
26609 data-functions="{{ entry.functions }}"
26610 data-classes="{{ entry.classes }}"
26611 data-variables="{{ entry.variables }}"
26612 data-imports="{{ entry.imports }}"
26613 data-tests="{{ entry.test_count }}"
26614 data-branch="{{ entry.git_branch }}"
26615 data-commit="{{ entry.git_commit }}"
26616 data-has-json="{{ entry.has_json }}"
26617 data-html-url="/runs/html/{{ entry.run_id }}">
26618 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
26619 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
26620 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
26621 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary"><span class="skipped-pill">{{ entry.files_skipped|commas }} skipped</span></div></td>
26622 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
26623 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
26624 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
26625 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
26626 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
26627 <td class="report-cell">
26628 <div class="actions-cell">
26629 {% 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 %}
26630 {% 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 %}
26631 </div>
26632 {% if !entry.submodule_links.is_empty() %}
26633 <details class="submod-details">
26634 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
26635 <div class="submod-link-list">
26636 {% for sub in entry.submodule_links %}
26637 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
26638 {% endfor %}
26639 </div>
26640 </details>
26641 {% endif %}
26642 </td>
26643 </tr>
26644 {% endfor %}
26645 </tbody>
26646 </table>
26647 </div>
26648 <div class="pagination">
26649 <span class="pagination-info" id="pagination-info"></span>
26650 <div class="pagination-btns" id="pagination-btns"></div>
26651 <div class="flex-row">
26652 <span class="per-page-label">Show</span>
26653 <select class="per-page" id="per-page-sel">
26654 <option value="10">10 per page</option>
26655 <option value="25" selected>25 per page</option>
26656 <option value="50">50 per page</option>
26657 <option value="100">100 per page</option>
26658 </select>
26659 <span class="per-page-label" id="page-range-label"></span>
26660 </div>
26661 </div>
26662 {% endif %}
26663 </section>
26664 </div>
26665
26666 <footer class="site-footer">
26667 local code analysis - metrics, history and reports
26668 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
26669 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26670 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26671 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26672 · <a href="/api-docs" rel="noopener">REST API</a>
26673 </footer>
26674
26675 <script nonce="{{ csp_nonce }}">
26676 (function () {
26677 // ── Theme ──────────────────────────────────────────────────────────────
26678 var storageKey = 'oxide-sloc-theme';
26679 var body = document.body;
26680 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26681 var toggle = document.getElementById('theme-toggle');
26682 if (toggle) toggle.addEventListener('click', function () {
26683 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26684 body.classList.toggle('dark-theme', next === 'dark');
26685 try { localStorage.setItem(storageKey, next); } catch(e) {}
26686 });
26687
26688 // ── State ─────────────────────────────────────────────────────────────
26689 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
26690 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
26691 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
26692
26693 // Aggregate stats from first (most recent) row
26694 if (allRows.length) {
26695 var first = allRows[0];
26696 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();}
26697 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>':'');}
26698 setChipVal('agg-code', first.dataset.code);
26699 setChipVal('agg-files', first.dataset.files);
26700 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
26701 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
26702 Array.prototype.forEach.call(document.querySelectorAll('#history-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
26703 }
26704
26705 // ── Branch filter population ──────────────────────────────────────────
26706 (function() {
26707 var branches = {};
26708 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
26709 var sel = document.getElementById('branch-filter');
26710 if (sel) Object.keys(branches).sort().forEach(function(b) {
26711 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
26712 });
26713 })();
26714
26715 // ── Filter ────────────────────────────────────────────────────────────
26716 function getFilteredRows() {
26717 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
26718 var branch = ((document.getElementById('branch-filter') || {}).value || '');
26719 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
26720 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
26721 if (branch && (r.dataset.branch || '') !== branch) return false;
26722 return true;
26723 });
26724 }
26725
26726 // ── Pagination ────────────────────────────────────────────────────────
26727 function renderPage() {
26728 var filtered = getFilteredRows();
26729 var total = filtered.length;
26730 var totalPages = Math.max(1, Math.ceil(total / perPage));
26731 currentPage = Math.min(currentPage, totalPages);
26732 var start = (currentPage - 1) * perPage;
26733 var end = Math.min(start + perPage, total);
26734 var shown = {};
26735 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
26736 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
26737 r.style.display = shown[r.dataset.run] ? '' : 'none';
26738 });
26739 var rl = document.getElementById('page-range-label');
26740 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
26741 var info = document.getElementById('pagination-info');
26742 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
26743 var btns = document.getElementById('pagination-btns');
26744 if (!btns) return;
26745 btns.innerHTML = '';
26746 function makeBtn(lbl, pg, active, disabled) {
26747 var b = document.createElement('button');
26748 b.className = 'pg-btn' + (active ? ' active' : '');
26749 b.textContent = lbl; b.disabled = disabled;
26750 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
26751 return b;
26752 }
26753 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
26754 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26755 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
26756 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
26757 }
26758
26759 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
26760 window.applyFilters = function() { currentPage = 1; renderPage(); };
26761
26762 // ── Sorting ───────────────────────────────────────────────────────────
26763 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
26764 function doSort(col, type, order) {
26765 var tbody = document.getElementById('history-tbody');
26766 if (!tbody) return;
26767 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26768 rows.sort(function(a, b) {
26769 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
26770 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
26771 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26772 return va < vb ? 1 : va > vb ? -1 : 0;
26773 });
26774 rows.forEach(function(r) { tbody.appendChild(r); });
26775 currentPage = 1; renderPage();
26776 }
26777 sortHeaders.forEach(function(th) {
26778 th.addEventListener('click', function(e) {
26779 if (e.target.classList.contains('col-resize-handle')) return;
26780 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26781 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26782 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26783 th.classList.add('sort-' + sortOrder);
26784 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
26785 doSort(col, type, sortOrder);
26786 });
26787 });
26788
26789 // ── Column resize ─────────────────────────────────────────────────────
26790 (function() {
26791 var table = document.getElementById('history-table');
26792 if (!table) return;
26793 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26794 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
26795 ths.forEach(function(th, i) {
26796 var handle = th.querySelector('.col-resize-handle');
26797 if (!handle || !cols[i]) return;
26798 var startX, startW;
26799 handle.addEventListener('mousedown', function(e) {
26800 e.stopPropagation(); e.preventDefault();
26801 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26802 handle.classList.add('dragging');
26803 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26804 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26805 document.addEventListener('mousemove', onMove);
26806 document.addEventListener('mouseup', onUp);
26807 });
26808 });
26809 })();
26810
26811 // ── Full-commit hover tooltip ─────────────────────────────────────────
26812 // The commit chips live inside an overflow:auto table wrapper, which would
26813 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
26814 // (escaping the scroll container) and follow the cursor. Event delegation
26815 // keeps it working after pagination/sorting re-renders the rows.
26816 (function() {
26817 var tip = document.createElement('div');
26818 tip.className = 'commit-tip';
26819 tip.setAttribute('role', 'tooltip');
26820 document.body.appendChild(tip);
26821 var shown = false;
26822 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
26823 function place(e) {
26824 var pad = 14, r = tip.getBoundingClientRect();
26825 var x = e.clientX + pad, y = e.clientY + pad;
26826 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
26827 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
26828 tip.style.left = x + 'px'; tip.style.top = y + 'px';
26829 }
26830 function hide() { tip.style.display = 'none'; shown = false; }
26831 document.addEventListener('mouseover', function(e) {
26832 var chip = chipFrom(e.target);
26833 if (!chip) return;
26834 var full = chip.getAttribute('data-full-commit');
26835 if (!full) return;
26836 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
26837 });
26838 document.addEventListener('mousemove', function(e) {
26839 if (!shown) return;
26840 if (chipFrom(e.target)) place(e); else hide();
26841 });
26842 document.addEventListener('mouseout', function(e) {
26843 if (chipFrom(e.target)) hide();
26844 });
26845 })();
26846
26847 // ── Reset view ────────────────────────────────────────────────────────
26848 window.resetView = function() {
26849 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
26850 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
26851 sortCol = null; sortOrder = 'asc';
26852 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26853 var tbody = document.getElementById('history-tbody');
26854 if (tbody) {
26855 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26856 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26857 rows.forEach(function(r) { tbody.appendChild(r); });
26858 }
26859 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
26860 var table = document.getElementById('history-table');
26861 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26862 currentPage = 1; renderPage();
26863 };
26864
26865 renderPage();
26866
26867 // ── Export helpers ────────────────────────────────────────────────────
26868 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
26869 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26870 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);}
26871 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;');}
26872 function slocXlsx(fname,sheet,hdrs,rows){
26873 var enc=new TextEncoder();
26874 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;}
26875 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;}
26876 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26877 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26878 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26879 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;}
26880 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
26881 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];}
26882 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26883 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
26884 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
26885 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
26886 +'<fonts count="2">'
26887 +'<font><sz val="11"/><name val="Calibri"/></font>'
26888 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
26889 +'</fonts>'
26890 +'<fills count="3">'
26891 +'<fill><patternFill patternType="none"/></fill>'
26892 +'<fill><patternFill patternType="gray125"/></fill>'
26893 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
26894 +'</fills>'
26895 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
26896 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
26897 +'<cellXfs count="4">'
26898 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
26899 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
26900 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
26901 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
26902 +'</cellXfs>'
26903 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
26904 +'</styleSheet>';
26905 var rx='<row r="1">';
26906 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
26907 rx+='</row>';
26908 rows.forEach(function(row,ri){
26909 var rn=ri+2;rx+='<row r="'+rn+'">';
26910 row.forEach(function(cell,c){
26911 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
26912 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
26913 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
26914 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
26915 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
26916 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
26917 });
26918 rx+='</row>';
26919 });
26920 var lastCol=hdrs.length,lastRow=rows.length+1;
26921 var tableRef='A1:'+colNm(lastCol)+lastRow;
26922 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26923 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
26924 +'<autoFilter ref="'+tableRef+'"/>'
26925 +'<tableColumns count="'+lastCol+'">'
26926 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
26927 +'</tableColumns>'
26928 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
26929 +'</table>';
26930 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26931 +'<Relationships xmlns="'+pns+'relationships">'
26932 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
26933 +'</Relationships>';
26934 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>';
26935 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
26936 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
26937 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
26938 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
26939 +'</worksheet>';
26940 var F={
26941 '[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/></Types>',
26942 '_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>',
26943 '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>',
26944 '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>',
26945 'xl/styles.xml':stl,
26946 'xl/sharedStrings.xml':ssXml,
26947 'xl/worksheets/sheet1.xml':sh,
26948 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
26949 'xl/tables/table1.xml':tableXml
26950 };
26951 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/_rels/sheet1.xml.rels','xl/tables/table1.xml'];
26952 var zparts=[],zcds=[],zoff=0,znf=0;
26953 order.forEach(function(name){
26954 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26955 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]);
26956 var entry=new Uint8Array(lha.length+nb.length+sz);
26957 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26958 zparts.push(entry);
26959 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));
26960 var cde=new Uint8Array(cda.length+nb.length);
26961 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26962 zcds.push(cde);zoff+=entry.length;znf++;
26963 });
26964 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26965 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]);
26966 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26967 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26968 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26969 zout.set(new Uint8Array(ea),zpos);
26970 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
26971 }
26972
26973 // Multi-sheet XLSX builder for the scan-history export.
26974 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
26975 function slocXlsxMulti(fname,sheets){
26976 var enc=new TextEncoder();
26977 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;}
26978 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;}
26979 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26980 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26981 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26982 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];}
26983 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;}
26984 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
26985 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26986 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
26987 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
26988 +'<fonts count="3">'
26989 +'<font><sz val="11"/><name val="Calibri"/></font>'
26990 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
26991 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
26992 +'</fonts>'
26993 +'<fills count="4">'
26994 +'<fill><patternFill patternType="none"/></fill>'
26995 +'<fill><patternFill patternType="gray125"/></fill>'
26996 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
26997 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
26998 +'</fills>'
26999 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27000 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27001 +'<cellXfs count="7">'
27002 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27003 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27004 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27005 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27006 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27007 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27008 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27009 +'</cellXfs>'
27010 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27011 +'</styleSheet>';
27012 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27013 sheets.forEach(function(sh,sheetIdx){
27014 var rx='<row r="1">';
27015 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27016 rx+='</row>';
27017 var rn=2;
27018 sh.rows.forEach(function(row){
27019 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27020 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27021 rx+='<row r="'+rn+'">';
27022 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27023 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27024 rx+='</row>';rn++;return;
27025 }
27026 rx+='<row r="'+rn+'">';
27027 row.forEach(function(cell,c){
27028 var ref=colRef(c,rn);
27029 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27030 if(typeof cell==='object'&&cell!==null){
27031 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27032 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27033 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27034 return;
27035 }
27036 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27037 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27038 });
27039 rx+='</row>';rn++;
27040 });
27041 var cw='';
27042 if(sh.colWidths&&sh.colWidths.length>0){
27043 cw='<cols>';
27044 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27045 cw+='</cols>';
27046 }
27047 var tblParts='';
27048 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27049 tableCounter++;
27050 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27051 var tRef='A1:'+colNm(colCount)+rowCount;
27052 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27053 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27054 +'<autoFilter ref="'+tRef+'"/>'
27055 +'<tableColumns count="'+colCount+'">'
27056 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27057 +'</tableColumns>'
27058 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27059 +'</table>';
27060 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27061 +'<Relationships xmlns="'+pns+'relationships">'
27062 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27063 +'</Relationships>';
27064 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27065 }
27066 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27067 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27068 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27069 });
27070 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>';
27071 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27072 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27073 var ctXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'+ctOver+ctTable+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>';
27074 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27075 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27076 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27077 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27078 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27079 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27080 var F={'[Content_Types].xml':ctXml,'_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':wbXml,'xl/_rels/workbook.xml.rels':wbRXml,'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml};
27081 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27082 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27083 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27084 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27085 var zparts=[],zcds=[],zoff=0,znf=0;
27086 order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});
27087 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27088 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]);
27089 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27090 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27091 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27092 zout.set(new Uint8Array(ea),zpos);
27093 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27094 }
27095
27096 var LANG_NAMES={'c':'C','cpp':'C++','c_sharp':'C#','go':'Go','java':'Java','java_script':'JavaScript','python':'Python','rust':'Rust','shell':'Shell','power_shell':'PowerShell','type_script':'TypeScript','assembly':'Assembly','clojure':'Clojure','css':'CSS','dart':'Dart','dockerfile':'Dockerfile','elixir':'Elixir','erlang':'Erlang','f_sharp':'F#','groovy':'Groovy','haskell':'Haskell','html':'HTML','julia':'Julia','kotlin':'Kotlin','lua':'Lua','makefile':'Makefile','nim':'Nim','objective_c':'Objective-C','ocaml':'OCaml','perl':'Perl','php':'PHP','r':'R','ruby':'Ruby','scala':'Scala','scss':'SCSS','sql':'SQL','svelte':'Svelte','swift':'Swift','vue':'Vue','xml':'XML','zig':'Zig','solidity':'Solidity','protobuf':'Protocol Buffers','hcl':'HCL/Terraform','graph_ql':'GraphQL','ada':'Ada','vhdl':'VHDL','verilog':'Verilog/SystemVerilog','tcl':'Tcl','pascal':'Pascal/Delphi','visual_basic':'Visual Basic','lisp':'Lisp/Scheme','fortran':'Fortran','nix':'Nix','crystal':'Crystal','d':'D','glsl':'GLSL/HLSL','cmake':'CMake','elm':'Elm','awk':'Awk'};
27097 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27098
27099 var _hh = ['Timestamp','Project','Run ID','Physical Lines','Code Lines','Comments','Blank Lines','Files Analyzed','Files Skipped','Functions','Classes','Variables','Imports','Tests','Code Density','Branch','Commit'];
27100 function getHistoryRows(){
27101 var r=[];
27102 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27103 var code=Number(tr.getAttribute('data-code'))||0;
27104 var phys=Number(tr.getAttribute('data-physical'))||0;
27105 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27106 r.push([
27107 tr.getAttribute('data-timestamp')||'',
27108 tr.getAttribute('data-project')||'',
27109 tr.getAttribute('data-run')||'',
27110 tr.getAttribute('data-physical')||'',
27111 tr.getAttribute('data-code')||'',
27112 tr.getAttribute('data-comments')||'',
27113 tr.getAttribute('data-blank')||'',
27114 tr.getAttribute('data-files')||'',
27115 tr.getAttribute('data-skipped')||'',
27116 tr.getAttribute('data-functions')||'',
27117 tr.getAttribute('data-classes')||'',
27118 tr.getAttribute('data-variables')||'',
27119 tr.getAttribute('data-imports')||'',
27120 tr.getAttribute('data-tests')||'',
27121 dens,
27122 tr.getAttribute('data-branch')||'',
27123 tr.getAttribute('data-commit')||''
27124 ]);
27125 });
27126 return r;
27127 }
27128 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27129 window.exportHistoryXls = function(){
27130 var histRows=getHistoryRows();
27131 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27132 var xlsxRows=histRows.map(function(r){return[r[0],r[1],r[2],toN(r[3]),toN(r[4]),toN(r[5]),toN(r[6]),toN(r[7]),toN(r[8]),toN(r[9]),toN(r[10]),toN(r[11]),toN(r[12]),toN(r[13]),{v:r[14],s:6},r[15],r[16]];});
27133 var histSheet={name:'Scan History',hdrs:_hh,rows:xlsxRows,colWidths:[18,14,22,14,12,12,12,12,12,11,10,10,10,8,13,10,12]};
27134 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27135 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27136 var runId=jsonRow.getAttribute('data-run')||'';
27137 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27138 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27139 fetch('/runs/json/'+runId)
27140 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27141 .then(function(run){
27142 var tot=run.summary_totals||{};
27143 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27144 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27145 function B(v){return{v:v,s:4};}
27146 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27147 var sumRows=[
27148 [{_sec:true,v:'RUN INFORMATION'}],
27149 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27150 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27151 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27152 [B('Branch'),run.git_branch||''],
27153 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27154 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27155 [B('Files Analyzed'),N(tot.files_analyzed)],
27156 [B('Files Skipped'),N(tot.files_skipped)],
27157 [],
27158 [{_sec:true,v:'CODE METRICS'}],
27159 [B('Physical Lines'),N(phys)],
27160 [B('Code Lines'),N(code)],
27161 [B('Comments'),N(tot.comment_lines)],
27162 [B('Blank Lines'),N(tot.blank_lines)],
27163 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27164 [B('Functions'),N(tot.functions)],
27165 [B('Classes / Types'),N(tot.classes)],
27166 [B('Variables'),N(tot.variables)],
27167 [B('Imports'),N(tot.imports)],
27168 [B('Tests'),N(tot.test_count)],
27169 [B('Assertions'),N(tot.test_assertion_count)],
27170 [B('Test Suites'),N(tot.test_suite_count)],
27171 [B('Code Density'),{v:dens,s:6}],
27172 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27173 ];
27174 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27175 var langRows=(run.totals_by_language||[]).map(function(l){
27176 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27177 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27178 return [langName(l.language),l.files||0,lp,lc,{v:ld,s:6},l.comment_lines||0,l.blank_lines||0,l.functions||0,l.classes||0,l.variables||0,l.imports||0,l.test_count||0,l.test_assertion_count||0,l.test_suite_count||0];
27179 });
27180 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27181 var pfRows=(run.per_file_records||[]).map(function(r){
27182 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27183 return [r.relative_path,langName(r.language),rc.total_physical_lines||0,ec.code_lines||0,ec.comment_lines||0,ec.blank_lines||0,rc.functions||0,rc.classes||0,rc.variables||0,rc.imports||0,rc.test_count||0,rc.test_assertion_count||0,r.size_bytes||0];
27184 });
27185 var skHdrs=['File','Status','Size (bytes)'];
27186 var skRows=(run.skipped_file_records||[]).map(function(r){
27187 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27188 });
27189 slocXlsxMulti('scan-history.xlsx',[
27190 histSheet,
27191 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27192 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27193 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27194 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27195 ]);
27196 })
27197 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27198 };
27199
27200 var csvBtn = document.getElementById('export-csv-btn');
27201 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27202 var xlsBtn = document.getElementById('export-xls-btn');
27203 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27204
27205 // ── Remaining CSP-safe event bindings ────────────────────────────────
27206 (function wireEvents() {
27207 var el;
27208 el = document.getElementById('reset-view-btn');
27209 if (el) el.addEventListener('click', window.resetView);
27210 el = document.getElementById('project-filter');
27211 if (el) el.addEventListener('input', window.applyFilters);
27212 el = document.getElementById('branch-filter');
27213 if (el) el.addEventListener('change', window.applyFilters);
27214 el = document.getElementById('per-page-sel');
27215 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27216 el = document.getElementById('add-watched-btn');
27217 if (el) el.addEventListener('click', function() {
27218 fetch('/pick-directory?kind=reports')
27219 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27220 .then(function(data) {
27221 if (!data.cancelled && data.selected_path) {
27222 var form = document.createElement('form');
27223 form.method = 'POST';
27224 form.action = '/watched-dirs/add';
27225 var ri = document.createElement('input');
27226 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27227 var fi = document.createElement('input');
27228 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27229 form.appendChild(ri); form.appendChild(fi);
27230 document.body.appendChild(form);
27231 form.submit();
27232 }
27233 })
27234 .catch(function(e) { alert('Could not open folder picker: ' + e); });
27235 });
27236 })();
27237
27238 (function randomizeWatermarks() {
27239 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27240 if (!wms.length) return;
27241 var placed = [];
27242 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;}
27243 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];}
27244 var half=Math.floor(wms.length/2);
27245 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;});
27246 })();
27247
27248 (function spawnCodeParticles() {
27249 var container = document.getElementById('code-particles');
27250 if (!container) return;
27251 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'];
27252 for (var i = 0; i < 38; i++) {
27253 (function(idx) {
27254 var el = document.createElement('span');
27255 el.className = 'code-particle';
27256 el.textContent = snippets[idx % snippets.length];
27257 var left = Math.random() * 94 + 2;
27258 var top = Math.random() * 88 + 6;
27259 var dur = (Math.random() * 10 + 9).toFixed(1);
27260 var delay = (Math.random() * 18).toFixed(1);
27261 var rot = (Math.random() * 26 - 13).toFixed(1);
27262 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27263 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';
27264 container.appendChild(el);
27265 })(i);
27266 }
27267 })();
27268 })();
27269 </script>
27270 <script nonce="{{ csp_nonce }}">
27271 (function(){
27272 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'}];
27273 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);});}
27274 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27275 function init(){
27276 var btn=document.getElementById('settings-btn');if(!btn)return;
27277 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27278 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>';
27279 document.body.appendChild(m);
27280 var g=document.getElementById('scheme-grid');
27281 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);});
27282 var cl=document.getElementById('settings-close');
27283 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);
27284 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');});
27285 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27286 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27287 }
27288 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27289 }());
27290 </script>
27291 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
27292</body>
27293</html>
27294"##,
27295 ext = "html"
27296)]
27297struct HistoryTemplate {
27298 version: &'static str,
27299 entries: Vec<HistoryEntryRow>,
27300 total_scans: usize,
27301 linked_count: usize,
27302 browse_error: Option<String>,
27303 watched_dirs: Vec<String>,
27304 csp_nonce: String,
27305 server_mode: bool,
27306}
27307
27308#[derive(Template)]
27311#[template(
27312 source = r##"
27313<!doctype html>
27314<html lang="en">
27315<head>
27316 <meta charset="utf-8">
27317 <meta name="viewport" content="width=device-width, initial-scale=1">
27318 <title>OxideSLOC | Compare Scans</title>
27319 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27320 <style nonce="{{ csp_nonce }}">
27321 :root {
27322 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27323 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27324 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27325 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27326 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27327 }
27328 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27329 *{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;}
27330 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27331 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27332 .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);}
27333 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27334 .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));}
27335 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27336 .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;}
27337 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27338 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27339 @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; } }
27340 .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;}
27341 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27342 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27343 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27344 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27345 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27346 .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;}
27347 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27348 .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);}
27349 .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;}
27350 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27351 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27352 .settings-modal-body{padding:14px 16px 16px;}
27353 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27354 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27355 .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;}
27356 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27357 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27358 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27359 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27360 .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;}
27361 .tz-select:focus{border-color:var(--oxide);}
27362 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27363 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27364 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27365 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27366 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27367 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27368 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27369 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27370 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27371 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27372 .per-page-label{font-size:13px;color:var(--muted);}
27373 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;}
27374 .filter-input{min-width:180px;cursor:text;}
27375 .table-wrap{width:100%;overflow-x:auto;}
27376 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27377 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;}
27378 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27379 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27380 #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;}
27381 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27382 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27383 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27384 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27385 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27386 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27387 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27388 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27389 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27390 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27391 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27392 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27393 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27394 tr:last-child td{border-bottom:none;}
27395 tr.selected td{background:var(--sel-bg);}
27396 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27397 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27398 tr{cursor:pointer;}
27399 tr.row-locked{opacity:.35;cursor:not-allowed;}
27400 tr.row-locked td{pointer-events:none;}
27401 .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;}
27402 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27403 .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;}
27404 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27405 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27406 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27407 .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);}
27408 .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);}
27409 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27410 .metric-num{font-weight:700;color:var(--text);}
27411 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27412 .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
27413 .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;}
27414 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27415 .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;}
27416 .btn:hover{background:var(--line);}
27417 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27418 .btn.primary:hover{opacity:.9;}
27419 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27420 .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;}
27421 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27422 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27423 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27424 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27425 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27426 .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;}
27427 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27428 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27429 .watched-chip-rm:hover{color:var(--oxide);}
27430 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27431 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27432 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27433 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27434 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27435 .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;}
27436 .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;}
27437 .btn-back:hover{background:var(--line);}
27438 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27439 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27440 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27441 .pagination-info{font-size:13px;color:var(--muted);}
27442 .pagination-btns{display:flex;gap:6px;}
27443 .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;}
27444 .pg-btn:hover:not(:disabled){background:var(--line);}
27445 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27446 .pg-btn:disabled{opacity:.35;cursor:default;}
27447 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27448 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27449 .site-footer a{color:var(--muted);}
27450 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27451 .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;}
27452 .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;}
27453 .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;}
27454 @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));}}
27455 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27456 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27457 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
27458 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27459 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27460 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27461 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
27462 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27463 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27464 .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;}
27465 .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;}
27466 .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%;}
27467 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27468 .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;}
27469 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27470 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27471 .hidden{display:none!important;}
27472 .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%;}
27473 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27474 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27475 .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;}
27476 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27477 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27478 .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;}
27479 .scope-option:hover{background:var(--line);}
27480 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27481 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27482 .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;}
27483 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27484 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27485 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27486 .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;}
27487 </style>
27488</head>
27489<body>
27490 <div class="background-watermarks" aria-hidden="true">
27491 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27492 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27493 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27494 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27495 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27496 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27497 </div>
27498 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27499 <div class="top-nav">
27500 <div class="top-nav-inner">
27501 <a class="brand" href="/">
27502 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27503 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27504 </a>
27505 <div class="nav-right">
27506 <a class="nav-pill" href="/">Home</a>
27507 <div class="nav-dropdown">
27508 <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>
27509 <div class="nav-dropdown-menu">
27510 <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>
27511 </div>
27512 </div>
27513 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27514 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27515 <div class="nav-dropdown">
27516 <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>
27517 <div class="nav-dropdown-menu">
27518 <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>
27519 </div>
27520 </div>
27521 <div class="server-status-wrap" id="server-status-wrap">
27522 <div class="nav-pill server-online-pill" id="server-status-pill">
27523 <span class="status-dot" id="status-dot"></span>
27524 <span id="server-status-label">Server</span>
27525 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27526 </div>
27527 <div class="server-status-tip">
27528 OxideSLOC is running — accessible on your network.
27529 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27530 </div>
27531 </div>
27532 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27533 <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>
27534 </button>
27535 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27536 <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>
27537 <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>
27538 </button>
27539 </div>
27540 </div>
27541 </div>
27542
27543 <div class="page">
27544 <div class="watched-bar">
27545 <div class="watched-bar-left">
27546 <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>
27547 <span class="watched-label">Watched Folders</span>
27548 <div class="watched-chips">
27549 {% if server_mode %}
27550 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27551 {% else %}
27552 {% for dir in watched_dirs %}
27553 <span class="watched-chip">
27554 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27555 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27556 <input type="hidden" name="folder_path" value="{{ dir }}">
27557 <input type="hidden" name="redirect_to" value="/compare-scans">
27558 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27559 </form>
27560 </span>
27561 {% endfor %}
27562 {% if watched_dirs.is_empty() %}
27563 <span class="watched-none">No folders watched — click Choose to add one</span>
27564 {% endif %}
27565 {% endif %}
27566 </div>
27567 </div>
27568 {% if !server_mode %}
27569 <div class="watched-bar-right">
27570 <button type="button" class="btn" id="add-watched-btn">
27571 <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>
27572 Choose
27573 </button>
27574 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27575 <input type="hidden" name="redirect_to" value="/compare-scans">
27576 <button type="submit" class="btn">↻ Refresh</button>
27577 </form>
27578 </div>
27579 {% endif %}
27580 </div>
27581 {% if total_scans > 0 %}
27582 <div class="summary-strip">
27583 <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>
27584 <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>
27585 <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>
27586 <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>
27587 </div>
27588 {% endif %}
27589 <section class="panel">
27590 <div class="panel-header">
27591 <div>
27592 <h1>Compare Scans</h1>
27593 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
27594 </div>
27595 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
27596 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
27597 <button class="btn primary" id="compare-btn" disabled>
27598 <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>
27599 Compare <span class="sel-count" id="sel-count">0</span> Selected
27600 </button>
27601 </div>
27602 </div>
27603 </div>
27604
27605 {% if entries.is_empty() %}
27606 <div class="empty-state">
27607 <strong>No scans yet</strong>
27608 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.
27609 </div>
27610 {% else %}
27611 <div class="filter-row">
27612 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27613 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27614 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27615 </div>
27616 <div class="scope-panel hidden" id="scope-panel">
27617 <div class="scope-panel-label">
27618 <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>
27619 Compare scope — choose what to include
27620 </div>
27621 <div class="scope-options" id="scope-options"></div>
27622 </div>
27623 {% if total_scans > 0 %}
27624 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
27625 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
27626 <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>
27627 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
27628 </div>
27629 </div>
27630 {% endif %}
27631 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
27632 <span class="compare-all-label">
27633 <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>
27634 Quick Compare All
27635 </span>
27636 </div>
27637 <div class="table-wrap">
27638 <table id="compare-table">
27639 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
27640 <thead>
27641 <tr id="compare-thead">
27642 <th><div class="col-resize-handle"></div></th>
27643 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27644 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27645 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
27646 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27647 <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>
27648 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27649 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27650 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27651 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27652 <th>Submodules<div class="col-resize-handle"></div></th>
27653 </tr>
27654 </thead>
27655 <tbody id="compare-tbody">
27656 {% for entry in entries %}
27657 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
27658 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
27659 data-project="{{ entry.project_label }}"
27660 data-files="{{ entry.files_analyzed }}"
27661 data-code="{{ entry.code_lines }}"
27662 data-comments="{{ entry.comment_lines }}"
27663 data-blank="{{ entry.blank_lines }}"
27664 data-branch="{{ entry.git_branch }}"
27665 data-commit="{{ entry.git_commit }}"
27666 data-submodules="{{ entry.submodule_names_csv }}">
27667 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
27668 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27669 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27670 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
27671 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
27672 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27673 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27674 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27675 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
27676 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" style="cursor:help;" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
27677 <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>
27678 </tr>
27679 {% endfor %}
27680 </tbody>
27681 </table>
27682 </div>
27683 <div class="pagination">
27684 <span class="pagination-info" id="pagination-info"></span>
27685 <div class="pagination-btns" id="pagination-btns"></div>
27686 <div class="flex-row">
27687 <span class="per-page-label">Show</span>
27688 <select class="per-page" id="per-page-sel">
27689 <option value="10">10 per page</option>
27690 <option value="25" selected>25 per page</option>
27691 <option value="50">50 per page</option>
27692 <option value="100">100 per page</option>
27693 </select>
27694 <span class="per-page-label" id="page-range-label"></span>
27695 </div>
27696 </div>
27697 {% endif %}
27698 </section>
27699 </div>
27700
27701 <footer class="site-footer">
27702 local code analysis - metrics, history and reports
27703 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27704 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27705 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27706 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27707 · <a href="/api-docs" rel="noopener">REST API</a>
27708 </footer>
27709
27710 <script nonce="{{ csp_nonce }}">
27711 (function () {
27712 // ── Theme ──────────────────────────────────────────────────────────────
27713 var storageKey = 'oxide-sloc-theme';
27714 var body = document.body;
27715 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27716 var toggle = document.getElementById('theme-toggle');
27717 if (toggle) toggle.addEventListener('click', function () {
27718 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27719 body.classList.toggle('dark-theme', next === 'dark');
27720 try { localStorage.setItem(storageKey, next); } catch(e) {}
27721 });
27722
27723 // ── State ─────────────────────────────────────────────────────────────
27724 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
27725 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
27726 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27727 window._allCompareRows = allRows;
27728
27729 // ── Stat chips ────────────────────────────────────────────────────────
27730 (function() {
27731 var projects = {}, latestTs = '', latestRow = null;
27732 allRows.forEach(function(r) {
27733 var p = r.dataset.project || ''; if (p) projects[p] = true;
27734 var ts = r.dataset.timestamp || '';
27735 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
27736 });
27737 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();}
27738 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>':'');}
27739 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
27740 if (latestRow) {
27741 setChipVal('agg-code', latestRow.dataset.code);
27742 setChipVal('agg-files', latestRow.dataset.files);
27743 }
27744 Array.prototype.forEach.call(document.querySelectorAll('#compare-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
27745 })();
27746
27747 // ── Branch filter population ──────────────────────────────────────────
27748 (function() {
27749 var branches = {};
27750 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27751 var sel = document.getElementById('branch-filter');
27752 if (sel) Object.keys(branches).sort().forEach(function(b) {
27753 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27754 });
27755 })();
27756
27757 // ── Filter ────────────────────────────────────────────────────────────
27758 function getFilteredRows() {
27759 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27760 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27761 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
27762 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27763 if (branch && (r.dataset.branch || '') !== branch) return false;
27764 return true;
27765 });
27766 }
27767
27768 // ── Pagination ────────────────────────────────────────────────────────
27769 function renderPage() {
27770 var filtered = getFilteredRows();
27771 var total = filtered.length;
27772 var totalPages = Math.max(1, Math.ceil(total / perPage));
27773 currentPage = Math.min(currentPage, totalPages);
27774 var start = (currentPage - 1) * perPage;
27775 var end = Math.min(start + perPage, total);
27776 var shown = {};
27777 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27778 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
27779 r.style.display = shown[r.dataset.run] ? '' : 'none';
27780 });
27781 var rl = document.getElementById('page-range-label');
27782 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27783 var info = document.getElementById('pagination-info');
27784 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27785 var btns = document.getElementById('pagination-btns');
27786 if (!btns) return;
27787 btns.innerHTML = '';
27788 function makeBtn(lbl, pg, active, disabled) {
27789 var b = document.createElement('button');
27790 b.className = 'pg-btn' + (active ? ' active' : '');
27791 b.textContent = lbl; b.disabled = disabled;
27792 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27793 return b;
27794 }
27795 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27796 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27797 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27798 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27799 }
27800
27801 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27802 window.applyFilters = function() { currentPage = 1; renderPage(); };
27803
27804 // ── Sorting ───────────────────────────────────────────────────────────
27805 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
27806 function doSort(col, type, order) {
27807 var tbody = document.getElementById('compare-tbody');
27808 if (!tbody) return;
27809 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27810 rows.sort(function(a, b) {
27811 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27812 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27813 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27814 return va < vb ? 1 : va > vb ? -1 : 0;
27815 });
27816 rows.forEach(function(r) { tbody.appendChild(r); });
27817 currentPage = 1; renderPage();
27818 }
27819 sortHeaders.forEach(function(th) {
27820 th.addEventListener('click', function(e) {
27821 if (e.target.classList.contains('col-resize-handle')) return;
27822 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27823 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27824 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27825 th.classList.add('sort-' + sortOrder);
27826 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27827 doSort(col, type, sortOrder);
27828 });
27829 });
27830
27831 // Apply default sort (timestamp desc) on initial load
27832 (function() {
27833 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
27834 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
27835 })();
27836
27837 // ── Column resize ─────────────────────────────────────────────────────
27838 (function() {
27839 var table = document.getElementById('compare-table');
27840 if (!table) return;
27841 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27842 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
27843 ths.forEach(function(th, i) {
27844 var handle = th.querySelector('.col-resize-handle');
27845 if (!handle || !cols[i]) return;
27846 var startX, startW;
27847 handle.addEventListener('mousedown', function(e) {
27848 e.stopPropagation(); e.preventDefault();
27849 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27850 handle.classList.add('dragging');
27851 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27852 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27853 document.addEventListener('mousemove', onMove);
27854 document.addEventListener('mouseup', onUp);
27855 });
27856 });
27857 })();
27858
27859 // ── Full-commit hover tooltip ─────────────────────────────────────────
27860 // The commit chips live inside an overflow:auto table wrapper, which would
27861 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27862 // (escaping the scroll container) and follow the cursor. Event delegation
27863 // keeps it working after pagination/sorting re-renders the rows.
27864 (function() {
27865 var tip = document.createElement('div');
27866 tip.className = 'commit-tip';
27867 tip.setAttribute('role', 'tooltip');
27868 document.body.appendChild(tip);
27869 var shown = false;
27870 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27871 function place(e) {
27872 var pad = 14, r = tip.getBoundingClientRect();
27873 var x = e.clientX + pad, y = e.clientY + pad;
27874 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27875 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27876 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27877 }
27878 function hide() { tip.style.display = 'none'; shown = false; }
27879 document.addEventListener('mouseover', function(e) {
27880 var chip = chipFrom(e.target);
27881 if (!chip) return;
27882 var full = chip.getAttribute('data-full-commit');
27883 if (!full) return;
27884 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27885 });
27886 document.addEventListener('mousemove', function(e) {
27887 if (!shown) return;
27888 if (chipFrom(e.target)) place(e); else hide();
27889 });
27890 document.addEventListener('mouseout', function(e) {
27891 if (chipFrom(e.target)) hide();
27892 });
27893 })();
27894
27895 // ── Reset view ────────────────────────────────────────────────────────
27896 window.resetView = function() {
27897 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27898 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27899 sortCol = null; sortOrder = 'asc';
27900 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27901 var tbody = document.getElementById('compare-tbody');
27902 if (tbody) {
27903 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27904 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27905 rows.forEach(function(r) { tbody.appendChild(r); });
27906 }
27907 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27908 var table = document.getElementById('compare-table');
27909 currentPage = 1; renderPage();
27910 currentPage = 1; renderPage();
27911 };
27912
27913 renderPage();
27914 buildCompareAllBar();
27915
27916 // ── Row selection state ───────────────────────────────────────────────
27917 var selected = [];
27918 var lockedProject = null; // project label of first selected scan
27919
27920 function updateCompareBtn() {
27921 var btn = document.getElementById('compare-btn');
27922 var cnt = document.getElementById('sel-count');
27923 if (!btn) return;
27924 btn.disabled = selected.length < 2;
27925 if (cnt) cnt.textContent = selected.length;
27926 }
27927
27928 function applyProjectLock() {
27929 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27930 allRows.forEach(function(r) {
27931 if (lockedProject === null) {
27932 r.classList.remove('row-locked');
27933 } else {
27934 var proj = r.dataset.project || '';
27935 if (proj !== lockedProject) {
27936 r.classList.add('row-locked');
27937 } else {
27938 r.classList.remove('row-locked');
27939 }
27940 }
27941 });
27942 }
27943
27944 function toggleRow(row) {
27945 if (row.classList.contains('row-locked')) return;
27946 var vid = row.dataset.vid || row.dataset.run;
27947 var idx = selected.indexOf(vid);
27948 if (idx >= 0) {
27949 selected.splice(idx, 1);
27950 row.classList.remove('selected');
27951 var b = document.getElementById('badge-' + vid);
27952 if (b) b.textContent = '';
27953 // Release project lock if nothing selected
27954 if (selected.length === 0) lockedProject = null;
27955 } else {
27956 // Set project lock on first selection
27957 if (selected.length === 0) lockedProject = row.dataset.project || null;
27958 selected.push(vid);
27959 row.classList.add('selected');
27960 }
27961 selected.forEach(function(v, i) {
27962 var b = document.getElementById('badge-' + v);
27963 if (b) b.textContent = i + 1;
27964 });
27965 applyProjectLock();
27966 updateCompareBtn();
27967 buildScopePanel();
27968 }
27969
27970 // ── Compare-All bar ───────────────────────────────────────────────────
27971 function buildCompareAllBar() {
27972 var bar = document.getElementById('compare-all-bar');
27973 if (!bar) return;
27974 // Group all rows by project label.
27975 var groups = {};
27976 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27977 // Use all rows from the source data (not just visible).
27978 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27979 // We need ALL rows across all pages, not just the rendered ones.
27980 // Use the underlying allRows array that the pagination JS also uses.
27981 var sourceRows = window._allCompareRows || allRowsAll;
27982 sourceRows.forEach(function(r) {
27983 var proj = r.dataset.project || '';
27984 var vid = r.dataset.vid || r.dataset.run || '';
27985 if (!proj || !vid) return;
27986 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
27987 groups[proj].ids.push(vid);
27988 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
27989 });
27990 // Build buttons for each project with >= 2 scans.
27991 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
27992 if (!keys.length) { bar.style.display = 'none'; return; }
27993 bar.style.display = 'flex';
27994 // Remove old buttons (keep label).
27995 var oldBtns = bar.querySelectorAll('.compare-all-btn');
27996 oldBtns.forEach(function(b) { b.remove(); });
27997 keys.sort();
27998 keys.forEach(function(proj) {
27999 var g = groups[proj];
28000 var btn = document.createElement('button');
28001 btn.className = 'compare-all-btn';
28002 btn.type = 'button';
28003 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28004 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28005 btn.addEventListener('click', function() {
28006 // Sort ids by timestamp (ascending).
28007 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28008 pairs.sort(function(a, b) { return a.ts - b.ts; });
28009 var sorted = pairs.map(function(p) { return p.id; });
28010 if (sorted.length === 2) {
28011 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28012 } else {
28013 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28014 }
28015 });
28016 bar.appendChild(btn);
28017 });
28018 }
28019
28020 // ── Scope panel ───────────────────────────────────────────────────────
28021 var selectedScope = 'all';
28022
28023 function buildScopePanel() {
28024 var panel = document.getElementById('scope-panel');
28025 var opts = document.getElementById('scope-options');
28026 if (!panel || !opts) return;
28027 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28028
28029 // Collect union of submodules from all selected rows.
28030 var allSubs = {};
28031 selected.forEach(function(vid) {
28032 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28033 if (!row) return;
28034 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28035 });
28036 var subList = Object.keys(allSubs).sort();
28037 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28038
28039 panel.classList.remove('hidden');
28040 opts.innerHTML = '';
28041
28042 function makeOption(value, label, title) {
28043 var div = document.createElement('div');
28044 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28045 div.dataset.scopeValue = value;
28046 if (title) div.title = title;
28047 var radio = document.createElement('span');
28048 radio.className = 'scope-option-radio';
28049 var lbl = document.createElement('span');
28050 lbl.textContent = label;
28051 div.appendChild(radio);
28052 div.appendChild(lbl);
28053 div.addEventListener('click', function() {
28054 selectedScope = value;
28055 opts.querySelectorAll('.scope-option').forEach(function(o) {
28056 o.classList.toggle('selected', o.dataset.scopeValue === value);
28057 });
28058 });
28059 return div;
28060 }
28061
28062 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28063 var sep = document.createElement('span');
28064 sep.className = 'scope-option-sep';
28065 opts.appendChild(sep);
28066 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28067 subList.forEach(function(s) {
28068 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28069 });
28070 }
28071
28072 function doCompare() {
28073 if (selected.length < 2) return;
28074 if (selected.length === 2) {
28075 // Two-scan delta (existing flow with scope support).
28076 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28077 if (selectedScope === 'super') url += '&scope=super';
28078 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28079 window.location.href = url;
28080 } else {
28081 // Multi-scan timeline (N >= 3) — pass scope params too.
28082 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28083 if (selectedScope === 'super') url += '&scope=super';
28084 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28085 window.location.href = url;
28086 }
28087 }
28088
28089 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28090 var cbtn = document.getElementById('compare-btn');
28091 if (cbtn) cbtn.addEventListener('click', doCompare);
28092 var pfEl = document.getElementById('project-filter');
28093 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28094 var bfEl = document.getElementById('branch-filter');
28095 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28096 var rvBtn = document.getElementById('reset-view-btn');
28097 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28098 var ppSel = document.getElementById('per-page-sel');
28099 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28100
28101 var cmpTbody = document.getElementById('compare-tbody');
28102 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28103 var row = e.target.closest('.compare-row');
28104 if (row) toggleRow(row);
28105 });
28106
28107 (function randomizeWatermarks() {
28108 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28109 if (!wms.length) return;
28110 var placed = [];
28111 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;}
28112 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];}
28113 var half=Math.floor(wms.length/2);
28114 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;});
28115 })();
28116
28117 (function spawnCodeParticles() {
28118 var container = document.getElementById('code-particles');
28119 if (!container) return;
28120 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'];
28121 for (var i = 0; i < 38; i++) {
28122 (function(idx) {
28123 var el = document.createElement('span');
28124 el.className = 'code-particle';
28125 el.textContent = snippets[idx % snippets.length];
28126 var left = Math.random() * 94 + 2;
28127 var top = Math.random() * 88 + 6;
28128 var dur = (Math.random() * 10 + 9).toFixed(1);
28129 var delay = (Math.random() * 18).toFixed(1);
28130 var rot = (Math.random() * 26 - 13).toFixed(1);
28131 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28132 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';
28133 container.appendChild(el);
28134 })(i);
28135 }
28136 })();
28137
28138 // ── Watched folder picker ─────────────────────────────────────────────
28139 (function() {
28140 var btn = document.getElementById('add-watched-btn');
28141 if (!btn) return;
28142 btn.addEventListener('click', function() {
28143 fetch('/pick-directory?kind=reports')
28144 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28145 .then(function(data) {
28146 if (!data.cancelled && data.selected_path) {
28147 var form = document.createElement('form');
28148 form.method = 'POST';
28149 form.action = '/watched-dirs/add';
28150 var ri = document.createElement('input');
28151 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28152 var fi = document.createElement('input');
28153 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28154 form.appendChild(ri); form.appendChild(fi);
28155 document.body.appendChild(form);
28156 form.submit();
28157 }
28158 })
28159 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28160 });
28161 })();
28162
28163 // ── Submodule chip truncation ─────────────────────────────────────────
28164 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28165 var chips = cell.querySelectorAll('.submod-chip');
28166 var MAX = 4;
28167 if (chips.length <= MAX) return;
28168 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28169 var badge = document.createElement('span');
28170 badge.className = 'submod-overflow-badge';
28171 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28172 badge.textContent = '+' + (chips.length - MAX) + ' more';
28173 cell.appendChild(badge);
28174 cell.style.maxHeight = 'none';
28175 });
28176 })();
28177 </script>
28178 <script nonce="{{ csp_nonce }}">
28179 (function(){
28180 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'}];
28181 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);});}
28182 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28183 function init(){
28184 var btn=document.getElementById('settings-btn');if(!btn)return;
28185 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28186 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>';
28187 document.body.appendChild(m);
28188 var g=document.getElementById('scheme-grid');
28189 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);});
28190 var cl=document.getElementById('settings-close');
28191 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);
28192 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');});
28193 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28194 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28195 }
28196 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28197 }());
28198 </script>
28199 <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]';
28200 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;}
28201 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>
28202</body>
28203</html>
28204"##,
28205 ext = "html"
28206)]
28207struct CompareSelectTemplate {
28208 version: &'static str,
28209 entries: Vec<HistoryEntryRow>,
28210 total_scans: usize,
28211 watched_dirs: Vec<String>,
28212 csp_nonce: String,
28213 server_mode: bool,
28214}
28215
28216#[derive(Template)]
28219#[template(
28220 source = r##"
28221<!doctype html>
28222<html lang="en">
28223<head>
28224 <meta charset="utf-8">
28225 <meta name="viewport" content="width=device-width, initial-scale=1">
28226 <title>OxideSLOC | Scan Delta</title>
28227 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28228 <style nonce="{{ csp_nonce }}">
28229 :root {
28230 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28231 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28232 --nav:#283790; --nav-2:#013e6b;
28233 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28234 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28235 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28236 }
28237 body.dark-theme {
28238 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28239 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28240 }
28241 *{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;}
28242 .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);}
28243 .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;}
28244 .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));}
28245 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28246 .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;}
28247 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28248 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28249 @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; } }
28250 .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;}
28251 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28252 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28253 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28254 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28255 .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;}
28256 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28257 .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);}
28258 .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;}
28259 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28260 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28261 .settings-modal-body{padding:14px 16px 16px;}
28262 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28263 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28264 .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;}
28265 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28266 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28267 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28268 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28269 .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;}
28270 .tz-select:focus{border-color:var(--oxide);}
28271 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28272 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28273 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28274 .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;}
28275 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28276 .hero-body{display:block;}
28277 .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;}
28278 .btn-back:hover{background:var(--line);}
28279 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28280 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28281 .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;}
28282 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28283 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;}
28284 .muted{color:var(--muted);font-size:14px;}
28285 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28286 .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;}
28287 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28288 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28289 .vpill-arrow{font-size:20px;color:var(--muted);}
28290 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28291 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28292 .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;}
28293 .delta-card.delta-card-wide{padding:22px 24px;}
28294 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28295 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28296 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28297 .delta-card-from{font-size:15px;color:var(--muted);}
28298 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28299 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28300 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28301 .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%;}
28302 .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;}
28303 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28304 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28305 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28306 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28307 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28308 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28309 .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;}
28310 .meta-card-commit:hover{color:var(--oxide);}
28311 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28312 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28313 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28314 .meta-value{color:var(--text);font-size:13px;}
28315 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28316 .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;}
28317 .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);}
28318 .delta-card:hover .dc-tip{display:block;}
28319 .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;}
28320 .export-btn:hover{background:var(--line);}
28321 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28322 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28323 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28324 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28325 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28326 .delta-card-change.zero{color:var(--muted);background:transparent;}
28327 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28328 .delta-card-pct.pos{color:var(--pos);}
28329 .delta-card-pct.neg{color:var(--neg);}
28330 .delta-card-pct.zero{color:var(--muted);}
28331 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28332 .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;}
28333 .insight-card.insight-flag{border-color:var(--oxide);}
28334 .insight-card:hover .dc-tip{display:block;}
28335 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28336 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28337 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28338 .insight-label.flag{color:var(--oxide);}
28339 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28340 .insight-val.pos{color:var(--pos);}
28341 .insight-val.neg{color:var(--neg);}
28342 .insight-val.high{color:#c0392a;}
28343 .insight-val.med{color:#926000;}
28344 .insight-val.low{color:var(--pos);}
28345 body.dark-theme .insight-val.high{color:#ff6b6b;}
28346 body.dark-theme .insight-val.med{color:#f0c060;}
28347 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28348 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28349 .fc-row{display:flex;align-items:center;gap:8px;}
28350 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28351 .fc-label{color:var(--muted);}
28352 .fc-modified .fc-count{color:#926000;}
28353 .fc-added .fc-count{color:var(--pos);}
28354 .fc-removed .fc-count{color:var(--neg);}
28355 .fc-unchanged .fc-count{color:var(--muted);}
28356 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28357 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28358 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28359 .chip.modified{background:#fff2d8;color:#926000;}
28360 .chip.added{background:#e8f5ed;color:#1a8f47;}
28361 .chip.removed{background:#fdeaea;color:#b33b3b;}
28362 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28363 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28364 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28365 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28366 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28367 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28368 .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;}
28369 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28370 .tab-btn:hover:not(.active){background:var(--line);}
28371 .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;}
28372 .btn-reset:hover{background:var(--line);}
28373 .table-wrap{width:100%;overflow-x:auto;}
28374 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28375 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);}
28376 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28377 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28378 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28379 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28380 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28381 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28382 tr:last-child td{border-bottom:none;}
28383 tr:hover td{background:var(--surface-2);}
28384 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28385 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28386 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28387 /* Fixed layout: column widths come from the colgroup, not from scanning every
28388 row. With auto layout a large file matrix forces the browser to re-measure
28389 all cells on each reflow, which freezes the page during sort/resize. */
28390 #delta-table{table-layout:fixed;}
28391 #delta-table col:nth-child(1){width:32%;}
28392 #delta-table col:nth-child(2){width:11%;}
28393 #delta-table col:nth-child(3){width:11%;}
28394 #delta-table col:nth-child(4){width:16%;}
28395 #delta-table col:nth-child(5){width:10%;}
28396 #delta-table col:nth-child(6){width:10%;}
28397 #delta-table col:nth-child(7){width:10%;}
28398 tr.row-added td{background:rgba(26,143,71,0.04);}
28399 tr.row-removed td{background:rgba(179,59,59,0.06);}
28400 tr.row-modified td{background:rgba(146,96,0,0.04);}
28401 tr.row-unchanged td{color:var(--muted);}
28402 tr.row-unchanged .status-badge{opacity:.65;}
28403 .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;}
28404 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28405 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28406 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28407 .status-badge.modified{background:#fff2d8;color:#926000;}
28408 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28409 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28410 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28411 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28412 .delta-val{font-weight:700;}
28413 .delta-val.pos{color:var(--pos);}
28414 .delta-val.neg{color:var(--neg);}
28415 .delta-val.zero{color:var(--muted);}
28416 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28417 .from-to strong{color:var(--text);font-weight:700;}
28418 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28419 .from-to .ft-absent{color:var(--muted);font-weight:600;}
28420 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28421 .site-footer a{color:var(--muted);}
28422 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;}
28423 body.pdf-mode{background:#fff!important;}
28424 body.pdf-mode .page{padding:4px 6px 4px!important;}
28425 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28426 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28427 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28428 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28429 .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;}
28430 .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;}
28431 .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;}
28432 @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));}}
28433 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28434 .path-link:hover{color:var(--oxide-2);}
28435 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28436 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28437 a.vpill-id:hover{color:var(--oxide);}
28438 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28439 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28440 .pagination-info{font-size:13px;color:var(--muted);}
28441 .pagination-btns{display:flex;gap:6px;}
28442 .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;}
28443 .pg-btn:hover:not(:disabled){background:var(--line);}
28444 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28445 .pg-btn:disabled{opacity:.35;cursor:default;}
28446 .per-page-label{font-size:13px;color:var(--muted);}
28447 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;}
28448 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28449 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28450 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28451 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28452 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28453 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28454 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28455 .tab-btn.tab-unchanged{color:var(--muted);}
28456 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28457 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28458 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28459 .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;}
28460 .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;}
28461 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28462 .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;}
28463 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28464 .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;}
28465 .submod-scope-btn:hover{background:var(--line);}
28466 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28467 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28468 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28469 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28470 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28471 body.dark-theme .ic-card{background:var(--surface-2);}
28472 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28473 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28474 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28475 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28476 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28477 .ic-cb{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}.ic-cb:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
28478 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28479 .ic-card-h2-row .ic-card-h2{margin:0;}
28480 .ic-expand-btn{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;margin-left:auto;}
28481 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28482 .ic-svg-modal-ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
28483 .ic-svg-modal-ov.open{display:flex;}
28484 .ic-svg-modal{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:1100px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
28485 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28486 .ic-svg-modal-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}
28487 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28488 .ic-svg-modal-close{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}
28489 .ic-svg-modal-close:hover{background:var(--line);}
28490 .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;}
28491 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28492 .chart-metric-btn:hover:not(.active){background:var(--line);}
28493 .chart-wrap{width:100%;overflow-x:auto;}
28494 #cmp-tl-svg{display:block;width:100%;}
28495 .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);}
28496 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28497 #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;}
28498 </style>
28499</head>
28500<body>
28501 {{ loading_overlay|safe }}
28502 <div class="background-watermarks" aria-hidden="true">
28503 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28504 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28505 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28506 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28507 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28508 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28509 </div>
28510 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28511 <div class="top-nav">
28512 <div class="top-nav-inner">
28513 <a class="brand" href="/">
28514 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28515 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28516 </a>
28517 <div class="nav-right">
28518 <a class="nav-pill" href="/">Home</a>
28519 <div class="nav-dropdown">
28520 <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>
28521 <div class="nav-dropdown-menu">
28522 <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>
28523 </div>
28524 </div>
28525 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28526 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28527 <div class="nav-dropdown">
28528 <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>
28529 <div class="nav-dropdown-menu">
28530 <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>
28531 </div>
28532 </div>
28533 <div class="server-status-wrap" id="server-status-wrap">
28534 <div class="nav-pill server-online-pill" id="server-status-pill">
28535 <span class="status-dot" id="status-dot"></span>
28536 <span id="server-status-label">Server</span>
28537 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28538 </div>
28539 <div class="server-status-tip">
28540 OxideSLOC is running — accessible on your network.
28541 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28542 </div>
28543 </div>
28544 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28545 <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>
28546 </button>
28547 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28548 <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>
28549 <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>
28550 </button>
28551 </div>
28552 </div>
28553 </div>
28554
28555 <div class="page">
28556 <section class="hero">
28557 <div class="hero-header">
28558 <div>
28559 <h1 class="delta-title">Scan Delta</h1>
28560 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
28561 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
28562 {% if let Some(sub) = active_submodule %}
28563 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
28564 {% else if super_scope_active %}
28565 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
28566 {% else %}
28567 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
28568 {% endif %}
28569 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
28570 </div>
28571 </div>
28572 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
28573 <a class="btn-back" href="/compare-scans">
28574 <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>
28575 Compare Scans
28576 </a>
28577 <div class="export-group" style="margin-top:12px;">
28578 <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>
28579 <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>
28580 </div>
28581 </div>
28582 </div>
28583 {% if has_any_submodule_data %}
28584 <div class="submod-scope-bar">
28585 <span class="submod-scope-label">
28586 <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>
28587 Scope:
28588 </span>
28589 <div class="submod-scope-divider"></div>
28590 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
28591 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
28592 title="All files — super-repo and all submodules combined">Full scan</a>
28593 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
28594 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
28595 title="Only files that are not part of any submodule">Super-repo only</a>
28596 {% for sub in submodule_options %}
28597 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
28598 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
28599 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
28600 {% endfor %}
28601 </div>
28602 {% endif %}
28603 <div class="hero-body">
28604 <div class="meta-strip">
28605 <div class="delta-card delta-card-meta">
28606 <div class="meta-card-header">
28607 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
28608 <div class="meta-card-project-col">
28609 <div class="meta-card-project">{{ project_name }}</div>
28610 {% if has_any_submodule_data %}
28611 {% if let Some(sub) = active_submodule %}
28612 <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>
28613 {% else if super_scope_active %}
28614 <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>
28615 {% else %}
28616 <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>
28617 {% endif %}
28618 {% endif %}
28619 </div>
28620 </div>
28621 {% if !baseline_git_commit.is_empty() %}
28622 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
28623 {% else %}
28624 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
28625 {% endif %}
28626 <div class="meta-card-rows">
28627 <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>
28628 <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>
28629 <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>
28630 <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>
28631 {% if let Some(tags) = baseline_git_tags %}
28632 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28633 {% endif %}
28634 </div>
28635 </div>
28636 <div class="delta-card delta-card-meta">
28637 <div class="meta-card-header">
28638 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
28639 <div class="meta-card-project-col">
28640 <div class="meta-card-project">{{ project_name }}</div>
28641 {% if has_any_submodule_data %}
28642 {% if let Some(sub) = active_submodule %}
28643 <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>
28644 {% else if super_scope_active %}
28645 <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>
28646 {% else %}
28647 <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>
28648 {% endif %}
28649 {% endif %}
28650 </div>
28651 </div>
28652 {% if !current_git_commit.is_empty() %}
28653 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
28654 {% else %}
28655 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
28656 {% endif %}
28657 <div class="meta-card-rows">
28658 <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>
28659 <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>
28660 <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>
28661 <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>
28662 {% if let Some(tags) = current_git_tags %}
28663 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28664 {% endif %}
28665 </div>
28666 </div>
28667 </div>
28668 <div class="delta-strip">
28669 <div class="delta-card">
28670 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
28671 <div class="delta-card-label">Code lines</div>
28672 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
28673 <div class="delta-card-to">{{ current_code_fmt }}</div>
28674 {% 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>
28675 {% 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>
28676 {% else %}<div class="delta-card-pct zero">±0%</div>
28677 {% endif %}
28678 </div>
28679 <div class="delta-card">
28680 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
28681 <div class="delta-card-label">Files analyzed</div>
28682 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
28683 <div class="delta-card-to">{{ current_files_fmt }}</div>
28684 {% 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>
28685 {% 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>
28686 {% else %}<div class="delta-card-pct zero">±0%</div>
28687 {% endif %}
28688 </div>
28689 <div class="delta-card">
28690 <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>
28691 <div class="delta-card-label">Comment lines</div>
28692 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
28693 <div class="delta-card-to">{{ current_comments_fmt }}</div>
28694 {% 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>
28695 {% 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>
28696 {% else %}<div class="delta-card-pct zero">±0%</div>
28697 {% endif %}
28698 </div>
28699 {{ coverage_delta_card|safe }}
28700 <div class="delta-card delta-card-wide">
28701 <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>
28702 <div class="delta-card-label">File changes</div>
28703 <div class="file-changes-grid">
28704 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
28705 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
28706 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
28707 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
28708 </div>
28709 </div>
28710 </div>
28711 <div class="insights-panel">
28712 <div class="insight-card">
28713 <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>
28714 <div class="insight-label">Lines Added</div>
28715 <div class="insight-val pos">+{{ code_lines_added }}</div>
28716 <div class="insight-sub">New or grown source lines</div>
28717 </div>
28718 <div class="insight-card">
28719 <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>
28720 <div class="insight-label">Lines Removed</div>
28721 <div class="insight-val neg">−{{ code_lines_removed }}</div>
28722 <div class="insight-sub">Deleted or shrunk source lines</div>
28723 </div>
28724 <div class="insight-card">
28725 <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>
28726 <div class="insight-label">Churn Rate</div>
28727 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
28728 <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>
28729 </div>
28730 {% if scope_flag %}
28731 <div class="insight-card insight-flag">
28732 <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>
28733 <div class="insight-label flag">Scope Signal</div>
28734 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
28735 <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>
28736 </div>
28737 {% endif %}
28738 </div>
28739 </div>
28740 </section>
28741
28742 <section class="panel" id="inline-charts-section">
28743 <div class="panel-title">Scan Delta Charts</div>
28744 <div class="ic-grid">
28745 <div class="ic-card" style="grid-column:span 2">
28746 <div class="ic-card-h2-row">
28747 <span class="ic-card-h2">Timeline</span>
28748 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
28749 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
28750 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
28751 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
28752 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
28753 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
28754 </div>
28755 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
28756 </div>
28757 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
28758 </div>
28759 <div class="ic-card">
28760 <div class="ic-card-h2-row"><span class="ic-card-h2">Code Metrics — Baseline vs Current</span><button class="ic-expand-btn" data-expand-src="ic-c1" data-expand-title="Code Metrics — Baseline vs Current">⤢ Full View</button></div>
28761 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#C45C10"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#2A6846"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#D4A017"></span><span style="color:#D4A017;font-weight:600">Comments</span></span></div>
28762 <div id="ic-c1"></div>
28763 </div>
28764 <div class="ic-card" id="ic-lang-card">
28765 <div class="ic-card-h2-row"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="ic-c3" data-expand-title="Language Code Delta">⤢ Full View</button></div>
28766 <div id="ic-c3"></div>
28767 </div>
28768 <div class="ic-card">
28769 <div class="ic-card-h2-row"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="ic-c2" data-expand-title="Delta by Metric">⤢ Full View</button></div>
28770 <div id="ic-c2"></div>
28771 </div>
28772 <div class="ic-card">
28773 <div class="ic-card-h2-row"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="ic-c4" data-expand-title="File Change Distribution">⤢ Full View</button></div>
28774 <div id="ic-c4"></div>
28775 </div>
28776 </div>
28777 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
28778 <div class="ic-svg-modal">
28779 <div class="ic-svg-modal-hdr">
28780 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
28781 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
28782 </div>
28783 <div id="ic-svg-modal-body"></div>
28784 </div>
28785 </div>
28786 </section>
28787
28788 <section class="panel">
28789 <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
28790 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
28791 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
28792 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
28793 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
28794 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
28795 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
28796 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
28797 </div>
28798 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28799 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
28800 <div class="export-group">
28801 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
28802 <button type="button" class="export-btn" id="delta-csv-btn">
28803 <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>
28804 CSV
28805 </button>
28806 <button type="button" class="export-btn" id="delta-xls-btn">
28807 <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>
28808 Excel
28809 </button>
28810 </div>
28811 </div>
28812 </div>
28813
28814 <div class="table-wrap">
28815 <table id="delta-table">
28816 <colgroup>
28817 <col>
28818 <col>
28819 <col>
28820 <col>
28821 <col>
28822 <col>
28823 <col>
28824 </colgroup>
28825 <thead>
28826 <tr id="delta-thead">
28827 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28828 <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>
28829 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28830 <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>
28831 <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>
28832 <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>
28833 <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>
28834 </tr>
28835 </thead>
28836 <tbody id="delta-tbody">
28837 {% for row in file_rows %}
28838 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
28839 data-path="{{ row.relative_path }}"
28840 data-language="{{ row.language }}"
28841 data-baseline-code="{{ row.baseline_code }}"
28842 data-current-code="{{ row.current_code }}"
28843 data-code-delta="{{ row.code_delta_str }}"
28844 data-comment-delta="{{ row.comment_delta_str }}"
28845 data-total-delta="{{ row.total_delta_str }}"
28846 data-orig-idx="">
28847 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
28848 <td class="hide-sm">{{ row.language }}</td>
28849 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
28850 <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>
28851 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
28852 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
28853 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
28854 </tr>
28855 {% endfor %}
28856 </tbody>
28857 </table>
28858 </div>
28859 <div class="pagination">
28860 <span class="pagination-info" id="pg-range-label"></span>
28861 <div class="pagination-btns" id="pg-btns"></div>
28862 <div class="flex-row">
28863 <span class="per-page-label">Show</span>
28864 <select class="per-page" id="per-page-sel">
28865 <option value="10">10 per page</option>
28866 <option value="25" selected>25 per page</option>
28867 <option value="50">50 per page</option>
28868 <option value="100">100 per page</option>
28869 </select>
28870 </div>
28871 </div>
28872 </section>
28873 </div>
28874
28875 <div id="ic-tt"></div>
28876
28877 <footer class="site-footer">
28878 local code analysis - metrics, history and reports
28879 · <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>
28880 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28881 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28882 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28883 · <a href="/api-docs" rel="noopener">REST API</a>
28884 </footer>
28885
28886 <script nonce="{{ csp_nonce }}">
28887 (function () {
28888 var storageKey = 'oxide-sloc-theme';
28889 var body = document.body;
28890 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28891 var toggle = document.getElementById('theme-toggle');
28892 if (toggle) toggle.addEventListener('click', function () {
28893 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28894 body.classList.toggle('dark-theme', next === 'dark');
28895 try { localStorage.setItem(storageKey, next); } catch(e) {}
28896 });
28897
28898 (function randomizeWatermarks() {
28899 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28900 if (!wms.length) return;
28901 var placed = [];
28902 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;}
28903 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];}
28904 var half=Math.floor(wms.length/2);
28905 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;});
28906 })();
28907
28908 (function spawnCodeParticles() {
28909 var container = document.getElementById('code-particles');
28910 if (!container) return;
28911 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'];
28912 for (var i = 0; i < 38; i++) {
28913 (function(idx) {
28914 var el = document.createElement('span');
28915 el.className = 'code-particle';
28916 el.textContent = snippets[idx % snippets.length];
28917 var left = Math.random() * 94 + 2;
28918 var top = Math.random() * 88 + 6;
28919 var dur = (Math.random() * 10 + 9).toFixed(1);
28920 var delay = (Math.random() * 18).toFixed(1);
28921 var rot = (Math.random() * 26 - 13).toFixed(1);
28922 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28923 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';
28924 container.appendChild(el);
28925 })(i);
28926 }
28927 })();
28928 })();
28929
28930 var activeStatusFilter = 'all';
28931 var deltaPerPage = 25, deltaCurrPage = 1;
28932
28933 function openFolder(path) {
28934 fetch('/open-path?path=' + encodeURIComponent(path))
28935 .then(function (r) { return r.json(); })
28936 .then(function (d) {
28937 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
28938 })
28939 .catch(function () {});
28940 }
28941
28942 // \u2500\u2500 File-matrix model (windowed render) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
28943 // The server renders every row once; we lift them into a plain-data array and
28944 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
28945 // filtering run on the array (no DOM churn) and each render rebuilds just one
28946 // page (~25 rows). This keeps every interaction O(page) instead of O(all
28947 // files): a 28k-row table previously re-touched every node on each click
28948 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
28949 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
28950
28951 function parseDeltaNum(str) {
28952 if (!str || str === '\u2014') return 0;
28953 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
28954 }
28955
28956 function captureDelta() {
28957 var tbody = document.getElementById('delta-tbody');
28958 if (!tbody) return;
28959 var rows = tbody.querySelectorAll('.delta-row');
28960 for (var i = 0; i < rows.length; i++) {
28961 var r = rows[i];
28962 DELTA.push({
28963 h: r.innerHTML,
28964 cls: r.className,
28965 path: r.getAttribute('data-path') || '',
28966 lang: r.getAttribute('data-language') || '',
28967 status: r.getAttribute('data-status') || '',
28968 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
28969 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
28970 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
28971 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
28972 td: parseDeltaNum(r.getAttribute('data-total-delta')),
28973 bcs: r.getAttribute('data-baseline-code') || '',
28974 ccs: r.getAttribute('data-current-code') || '',
28975 cds: r.getAttribute('data-code-delta') || '',
28976 cmds: r.getAttribute('data-comment-delta') || '',
28977 tds: r.getAttribute('data-total-delta') || ''
28978 });
28979 }
28980 tbody.innerHTML = '';
28981 }
28982
28983 function applyDeltaQuery() {
28984 var v = (activeStatusFilter === 'all') ? DELTA.slice()
28985 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
28986 if (sortCol) {
28987 var asc = sortOrder === 'asc';
28988 v.sort(function(a, b) {
28989 var va, vb;
28990 if (sortCol === 'path') { va = a.path; vb = b.path; }
28991 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
28992 else if (sortCol === 'status') { va = a.status; vb = b.status; }
28993 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
28994 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
28995 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
28996 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
28997 else { return 0; }
28998 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
28999 return va < vb ? 1 : va > vb ? -1 : 0;
29000 });
29001 }
29002 _deltaView = v;
29003 deltaCurrPage = 1;
29004 renderDeltaPage();
29005 }
29006
29007 function renderDeltaPage() {
29008 var total = _deltaView.length;
29009 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29010 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29011 if (deltaCurrPage < 1) deltaCurrPage = 1;
29012 var start = (deltaCurrPage - 1) * deltaPerPage;
29013 var end = Math.min(start + deltaPerPage, total);
29014 var tbody = document.getElementById('delta-tbody');
29015 if (tbody) {
29016 var html = '';
29017 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29018 tbody.innerHTML = html;
29019 }
29020 var rl = document.getElementById('pg-range-label');
29021 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29022 var btns = document.getElementById('pg-btns');
29023 if (!btns) return;
29024 btns.innerHTML = '';
29025 if (totalPages <= 1) return;
29026 function makeBtn(lbl, pg, active, disabled) {
29027 var b = document.createElement('button');
29028 b.className = 'pg-btn' + (active ? ' active' : '');
29029 b.textContent = lbl; b.disabled = disabled;
29030 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29031 return b;
29032 }
29033 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29034 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29035 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29036 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29037 }
29038
29039 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29040
29041 function filterRows(status, btn) {
29042 activeStatusFilter = status;
29043 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29044 b.classList.remove('active');
29045 });
29046 if (btn) btn.classList.add('active');
29047 applyDeltaQuery();
29048 }
29049
29050 // ── Sorting ──────────────────────────────────────────────────────────────
29051 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29052 sortHeaders.forEach(function(th) {
29053 th.addEventListener('click', function(e) {
29054 if (e.target.classList.contains('col-resize-handle')) return;
29055 var col = th.dataset.sortCol;
29056 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29057 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29058 th.classList.add('sort-' + sortOrder);
29059 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29060 applyDeltaQuery();
29061 });
29062 });
29063
29064 // ── Column resize ─────────────────────────────────────────────────────────
29065 (function() {
29066 var table = document.getElementById('delta-table');
29067 if (!table) return;
29068 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29069 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29070 ths.forEach(function(th, i) {
29071 var handle = th.querySelector('.col-resize-handle');
29072 if (!handle || !cols[i]) return;
29073 handle.addEventListener('mousedown', function(e) {
29074 e.stopPropagation(); e.preventDefault();
29075 // Lock every column to its current rendered px width and size the table
29076 // to the column total. With table-layout:fixed + width:100% the table is
29077 // pinned to the container, so widening one <col> only rebalances the rest
29078 // and the drag looks inert; pinning px widths lets the column actually
29079 // grow while the wrapper (overflow-x:auto) scrolls.
29080 var startTableW = 0;
29081 for (var k = 0; k < ths.length; k++) {
29082 if (!cols[k]) continue;
29083 var w = ths[k].getBoundingClientRect().width;
29084 cols[k].style.width = w + 'px';
29085 startTableW += w;
29086 }
29087 table.style.width = startTableW + 'px';
29088 var startX = e.clientX;
29089 var startW = ths[i].getBoundingClientRect().width;
29090 handle.classList.add('dragging');
29091 function onMove(ev) {
29092 var newW = Math.max(40, startW + ev.clientX - startX);
29093 cols[i].style.width = newW + 'px';
29094 table.style.width = (startTableW + (newW - startW)) + 'px';
29095 }
29096 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29097 document.addEventListener('mousemove', onMove);
29098 document.addEventListener('mouseup', onUp);
29099 });
29100 });
29101 })();
29102
29103 // ── Reset ─────────────────────────────────────────────────────────────────
29104 window.resetDeltaTable = function() {
29105 sortCol = null; sortOrder = 'asc';
29106 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29107 var table = document.getElementById('delta-table');
29108 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29109 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29110 activeStatusFilter = 'all';
29111 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29112 var allBtn = document.querySelector('.tab-btn');
29113 if (allBtn) allBtn.classList.add('active');
29114 applyDeltaQuery();
29115 };
29116
29117 // Compact number formatter (shared by the delta table; charts define their own locally)
29118 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();}
29119 function fmtFull(n){return Number(n).toLocaleString();}
29120
29121 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29122 function fmtFromTo() {
29123 var tbody = document.getElementById('delta-tbody');
29124 if (!tbody) return;
29125 tbody.querySelectorAll('.delta-row').forEach(function(row) {
29126 var status = row.dataset.status || '';
29127 var ft = row.querySelector('.from-to');
29128 if (!ft) return;
29129 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29130 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29131 var strongs = ft.querySelectorAll('strong');
29132 // Apply fmt() to non-absent strong values
29133 strongs.forEach(function(el) {
29134 var n = parseInt(el.textContent, 10);
29135 if (!isNaN(n)) el.textContent = fmtFull(n);
29136 });
29137 // Safety: force dash for genuinely absent sides
29138 if (status === 'added' && bv === 0) {
29139 var bs = ft.querySelector('strong:first-of-type');
29140 if (bs && bs.textContent === '0') {
29141 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29142 }
29143 }
29144 if (status === 'removed' && cv === 0) {
29145 var cs = ft.querySelector('strong:last-of-type');
29146 if (cs && cs.textContent === '0') {
29147 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29148 }
29149 }
29150 });
29151 }
29152 // Initialize: format the server-rendered rows, lift them into the data model
29153 // (which also clears the DOM), then render only the first page.
29154 fmtFromTo();
29155 captureDelta();
29156 applyDeltaQuery();
29157
29158 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29159 (function() {
29160 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29161 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29162 });
29163 var resetBtn = document.getElementById('delta-reset-btn');
29164 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29165 var csvBtn = document.getElementById('delta-csv-btn');
29166 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29167 var xlsBtn = document.getElementById('delta-xls-btn');
29168 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29169 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29170 function sdFetchUri(path) {
29171 return fetch(path).then(function(r){return r.blob();}).then(function(b){
29172 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29173 }).catch(function(){return '';});
29174 }
29175 function sdInlineImgs(html, cb) {
29176 var paths=[], seen={};
29177 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29178 if(!paths.length){cb(html);return;}
29179 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29180 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29181 .catch(function(){cb(html);});
29182 }
29183 function buildFullPageHtml(pdfMode) {
29184 if(pdfMode) document.body.classList.add('pdf-mode');
29185 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29186 renderDeltaPage();
29187 var html = document.documentElement.outerHTML;
29188 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29189 if(pdfMode) document.body.classList.remove('pdf-mode');
29190 return html;
29191 }
29192 var chartsBtn = document.getElementById('delta-charts-btn');
29193 if (chartsBtn) chartsBtn.addEventListener('click', function() {
29194 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29195 sdInlineImgs(buildFullPageHtml(false), function(html) {
29196 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29197 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29198 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29199 btn.disabled=false;btn.innerHTML=orig;
29200 });
29201 });
29202 var pageHtmlBtn = document.getElementById('page-export-html-btn');
29203 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29204 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29205 sdInlineImgs(buildFullPageHtml(false), function(html) {
29206 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29207 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29208 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29209 btn.disabled=false;btn.innerHTML=orig;
29210 });
29211 });
29212 // PDF export — clean document-style report, not a web page screenshot
29213 function buildDeltaPdfHtml() {
29214 var sd=_sd, dr=getDeltaExportRows();
29215 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29216 function pct(b,c){b=Number(b);c=Number(c);if(!b)return c>0?'new':'±0%';var v=(c-b)/b*100,t=v.toFixed(1);return(t==='0.0'||t==='-0.0')?'±0%':(v>0?'+':'')+t+'%';}
29217 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29218 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29219 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29220 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29221 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29222 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29223 function fmtN(n){return Number(n).toLocaleString();}
29224 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29225 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>';}
29226 var lm={};
29227 dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0,c=parseInt(r[4])||0;if(!lm[l])lm[l]={f:0,d:0,c:0};lm[l].f++;lm[l].d+=d;lm[l].c+=c;});
29228 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29229 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29230 // The header/footer flow in normal document order (NOT position:fixed).
29231 // A fixed header repeats on every printed page in Chromium and overlaps
29232 // the content beneath it — silently swallowing the first few table rows of
29233 // pages 2+ and clipping the summary cards on page 1. Letting the header
29234 // flow once at the top and relying on the table's <thead> (which Chromium
29235 // repeats per page) keeps every row visible. `.body` keeps a small inset
29236 // so nothing bleeds to the sheet edge.
29237 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29238 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29239 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29240 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29241 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29242 '.ph-brand em{color:#c45c10;font-style:normal;}'+
29243 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29244 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29245 '.info-bar{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29246 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29247 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29248 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29249 '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29250 '.body{padding:12px 18px 0;}'+
29251 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29252 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29253 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29254 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29255 '.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:8px 12px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
29256 '.meta>div{flex:1 1 0;}'+
29257 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29258 '.sec{margin-bottom:10px;}'+
29259 '.sh{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29260 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29261 '.pg-rhdr-in{display:flex;justify-content:space-between;align-items:center;padding:6px 11px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;}'+
29262 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29263 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29264 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29265 'th{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29266 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29267 'tr:nth-child(even) td{background:#faf8f6;}'+
29268 '.rfoot{position:fixed;left:0;right:0;bottom:0;height:20px;background:#1a2035;color:#9fb0c8;font-size:9px;display:flex;justify-content:space-between;align-items:center;padding:0 14px;box-sizing:border-box;z-index:99;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29269 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29270 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29271 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29272 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29273 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29274 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29275 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29276 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29277 '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29278 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29279 '.fcc-n{font-size:18px;font-weight:900;}'+
29280 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29281 var fileRows=dchg.map(function(r){
29282 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29283 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29284 '<td style="'+ss+'">'+esc(st)+'</td>'+
29285 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29286 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29287 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29288 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29289 var more='';
29290 var langRows=langs.map(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);return'<tr><td>'+esc(l)+'</td><td style="text-align:right">'+fmtN(e.f)+'</td><td style="text-align:right">'+fmtN(e.c)+'</td><td style="text-align:right">'+delt(dv)+'</td></tr>';}).join('');
29291 var extraCards='';
29292 if(Number(sd.btests||0)>0||Number(sd.ctests||0)>0){extraCards+='<div class="mcard"><div class="mc-l">Tests Detected</div><div class="mc-v">'+fullN(sd.ctests)+'</div><div class="mc-b">Before: '+fullN(sd.btests)+'</div><div class="mc-p '+pcls(sd.btests,sd.ctests)+'">'+pct(sd.btests,sd.ctests)+'</div></div>';}
29293 if(sd.bcov!=null||sd.ccov!=null){var _cc=(sd.ccov!=null?Number(sd.ccov).toFixed(1)+'%':'—'),_cb=(sd.bcov!=null?Number(sd.bcov).toFixed(1)+'%':'—');extraCards+='<div class="mcard"><div class="mc-l">Coverage</div><div class="mc-v">'+_cc+'</div><div class="mc-b">Before: '+_cb+'</div></div>';}
29294 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29295 '<div class="pdf-header">'+
29296 '<div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Scan Delta</div><div class="ph-date">'+esc(now)+'</div></div>'+
29297 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29298 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29299 '</div>'+
29300 '<div class="body">'+
29301 '<div class="sec"><p class="sh">Summary Metrics</p>'+
29302 '<div class="msec">'+
29303 '<div class="mcard"><div class="mc-l">Code Lines</div><div class="mc-v">'+fullN(sd.cc)+'</div><div class="mc-b">Before: '+fullN(sd.bc)+'</div><div class="mc-p '+pcls(sd.bc,sd.cc)+'">'+pct(sd.bc,sd.cc)+'</div></div>'+
29304 '<div class="mcard"><div class="mc-l">Files Analyzed</div><div class="mc-v">'+fullN(sd.cf)+'</div><div class="mc-b">Before: '+fullN(sd.bf)+'</div><div class="mc-p '+pcls(sd.bf,sd.cf)+'">'+pct(sd.bf,sd.cf)+'</div></div>'+
29305 '<div class="mcard"><div class="mc-l">Comment Lines</div><div class="mc-v">'+fullN(sd.ccm)+'</div><div class="mc-b">Before: '+fullN(sd.bcm)+'</div><div class="mc-p '+pcls(sd.bcm,sd.ccm)+'">'+pct(sd.bcm,sd.ccm)+'</div></div>'+
29306 '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v" style="color:#2a6846">+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
29307 '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v" style="color:#b23030">−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
29308 '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v" style="color:#1a2035">'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
29309 extraCards+'</div></div>'+
29310 '<div class="sec"><p class="sh">File Changes</p>'+
29311 '<div class="fcsec">'+
29312 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29313 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29314 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29315 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29316 '</div></div>'+
29317 (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
29318 '<div class="sec">'+
29319 '<table><thead>'+
29320 '<tr class="pg-rhdr"><th colspan="6"><div class="pg-rhdr-in"><span>File Delta · '+fmtN(dchg.length)+' changed of '+fmtN(dr.length)+' files</span><span class="pg-rhdr-r"><em>oxide</em>-sloc · Scan Delta · '+esc(projName)+'</span></div></th></tr>'+
29321 '<tr><th>File</th><th>Language</th><th>Status</th>'+
29322 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29323 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29324 '</div>'+
29325 '<div class="rfoot">'+
29326 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29327 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29328 '</div>'+
29329 '</body></html>';
29330 }
29331 function doDeltaPdf(btn) {
29332 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29333 }
29334 var pdfBtn = document.getElementById('delta-pdf-btn');
29335 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29336 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29337 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29338 if (location.protocol === 'file:') {
29339 [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'; } });
29340 [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'; } });
29341 }
29342 var ppSel = document.getElementById('per-page-sel');
29343 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29344 var pathLink = document.getElementById('project-path-link');
29345 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29346 })();
29347
29348 // ── Export helpers ────────────────────────────────────────────────────────
29349 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
29350 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29351 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);}
29352 function slocMakeXlsx(fname,sd,dr){
29353 var enc=new TextEncoder();
29354 // CRC-32 table
29355 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;}
29356 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;}
29357 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29358 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29359 // Shared string table
29360 var ss=[],si={};
29361 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29362 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29363 // Worksheet builder — each WS() call gets its own row counter R
29364 function WS(){
29365 var R=0,buf=[];
29366 function cl(c){return String.fromCharCode(65+c);}
29367 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29368 '<v>'+S(v)+'</v></c>';}
29369 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29370 (st?' s="'+st+'"':'')+'>'+
29371 '<v>'+(+v)+'</v></c>';}
29372 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29373 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29374 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29375 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29376 '<sheetFormatPr defaultRowHeight="15"/>'+
29377 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29378 return{sc:sc,nc:nc,row:row,xml:xml};
29379 }
29380 // Language breakdown
29381 var lm={};
29382 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;});
29383 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29384 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29385 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29386 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29387 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29388 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29389 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):'';}
29390 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29391 // Summary sheet
29392 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29393 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29394 r1(s1(0,proj,2));
29395 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29396 r1('');
29397 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29398 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))));
29399 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))));
29400 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))));
29401 r1('');
29402 r1(s1(0,'FILE CHANGES',8));
29403 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29404 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29405 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29406 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29407 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29408 if(langs.length){
29409 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29410 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29411 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)));});
29412 }
29413 r1('');r1(s1(0,'SCAN METADATA',8));
29414 r1(s1(1,_blabel)+s1(2,_clabel));
29415 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29416 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29417 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"/>');
29418 // File Delta sheet
29419 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29420 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));
29421 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)));});
29422 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29423 // Shared strings XML
29424 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29425 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29426 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29427 // XLSX file map
29428 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29429 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>',
29430 '_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>',
29431 '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>',
29432 '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>',
29433 '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>',
29434 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29435 // ZIP packer — STORED (no compression), compatible with all XLSX readers
29436 var zparts=[],zcds=[],zoff=0,znf=0;
29437 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29438 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29439 ].forEach(function(name){
29440 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29441 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]);
29442 var entry=new Uint8Array(lha.length+nb.length+sz);
29443 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29444 zparts.push(entry);
29445 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));
29446 var cde=new Uint8Array(cda.length+nb.length);
29447 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29448 zcds.push(cde);zoff+=entry.length;znf++;
29449 });
29450 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29451 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]);
29452 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29453 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29454 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29455 zout.set(new Uint8Array(ea),zpos);
29456 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29457 var xurl=URL.createObjectURL(xblob);
29458 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29459 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29460 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29461 }
29462 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;');}
29463 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29464 function getExportFilename(ext){return _exportBase+'.'+ext;}
29465
29466 var _sd = {bc:{{ baseline_code }},cc:{{ current_code }},cd:'{{ code_lines_delta_str }}',bf:{{ baseline_files }},cf:{{ current_files }},fd:'{{ files_analyzed_delta_str }}',bcm:{{ baseline_comments }},ccm:{{ current_comments }},cmd:'{{ comment_lines_delta_str }}',fm:{{ files_modified }},fa:{{ files_added }},fr:{{ files_removed }},fu:{{ files_unchanged }},bts:'{{ baseline_timestamp }}',cts:'{{ current_timestamp }}',bid:'{{ baseline_run_id_short }}',cid:'{{ current_run_id_short }}',bbr:'{{ baseline_git_branch }}',cbr:'{{ current_git_branch }}',btag:'{% if let Some(t) = baseline_git_tags %}{{ t }}{% endif %}',ctag:'{% if let Some(t) = current_git_tags %}{{ t }}{% endif %}',bsha:'{{ baseline_git_commit }}',csha:'{{ current_git_commit }}',btests:{{ baseline_test_count }},ctests:{{ current_test_count }},bcov:{% if let Some(p) = baseline_coverage_pct %}{{ p }}{% else %}null{% endif %},ccov:{% if let Some(p) = current_coverage_pct %}{{ p }}{% else %}null{% endif %},cla:{{ code_lines_added }},clr:{{ code_lines_removed }},churn:'{{ churn_rate_str }}'};
29467 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;}
29468 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29469 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29470 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29471 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29472 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):'';}
29473 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29474 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)]];}
29475 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29476 function getDeltaExportRows(){return DELTA.map(function(d){var b=parseInt(d.bcs)||0,c=parseInt(d.ccs)||0;return [d.path,d.lang,d.status,d.bcs,d.ccs,d.cds,d.cmds,d.tds,_filePct(b,c,d.status)];});}
29477 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29478 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29479
29480 // ── Chart HTML report ─────────────────────────────────────────────────────
29481 function slocChartReport(fname, sd, dr) {
29482 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29483 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29484 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29485 function fmt(n){return Number(n).toLocaleString();}
29486 function px(n){return Math.round(n);}
29487 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29488 // Language map
29489 var lm={};
29490 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;});
29491 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29492
29493 // Builds onmouse* attrs for interactive tooltip on each SVG element
29494 function barTT(label,val){
29495 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29496 }
29497
29498 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29499 // match the Language Code Delta column height) ────────────
29500 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#E3A876',cc:'#C45C10'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#9FC3AE',cc:'#2A6846'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#E0C58A',cc:'#BE8A2E'}];
29501 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29502 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29503 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29504 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29505 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"/>';}
29506 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29507 c1mets.forEach(function(m,i){
29508 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29509 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
29510 var gMax=Math.max(m.b,m.c)*1.15||1;
29511 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29512 c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="'+FONT_C+'" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
29513 c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
29514 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
29515 c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
29516 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
29517 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Before</text>';
29518 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">After</text>';
29519 });
29520 c1+='<text x="'+px(C1W/2)+'" y="'+(C1H-8)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Each metric uses its own scale — compare Before vs After within a metric</text>';
29521 c1+='</svg>';
29522
29523 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
29524 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#C45C10'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#2A6846'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#BE8A2E'}];
29525 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29526 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
29527 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29528 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29529 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29530 mets.forEach(function(m,i){
29531 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
29532 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
29533 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29534 c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
29535 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
29536 if(bw>=52){
29537 c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
29538 }else{
29539 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
29540 c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
29541 }
29542 });
29543 c2+='</svg>';
29544
29545 // ── Chart 3: Language Code Delta ─────────────────────────────────────
29546 var c3='';
29547 if(langs.length){
29548 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29549 var C3W=550,c3LW=124,c3FW=52;
29550 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
29551 var L3rH=30,C3H=langs.length*L3rH+20;
29552 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29553 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29554 langs.forEach(function(l,i){
29555 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
29556 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
29557 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29558 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#444">'+esc(l)+'</text>';
29559 c3+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"'+barTT(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+'/>';
29560 if(bw>=48){
29561 c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
29562 }else{
29563 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
29564 c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
29565 }
29566 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
29567 });
29568 c3+='</svg>';
29569 }
29570
29571 // ── Chart 4: File Change Donut — centered pie with legend below
29572 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;});
29573 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29574 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
29575 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">';
29576 var ang=-Math.PI/2;
29577 segs.forEach(function(s){
29578 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
29579 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
29580 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29581 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
29582 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29583 c4+='<path class="cb" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="white" stroke-width="2.5"'+barTT(s.l,fmt(s.v)+' files \u2022 '+px(s.v/tot*100)+'%')+'/>';
29584 ang+=sw;
29585 });
29586 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
29587 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" fill="#888">total files</text>';
29588 segs.forEach(function(s,i){
29589 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
29590 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
29591 c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
29592 });
29593 c4+='</svg>';
29594
29595 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
29596 var ttJs='var tt=document.getElementById("ox-tt");'+
29597 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
29598 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
29599 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
29600 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
29601 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
29602 'function oxHT(){tt.style.display="none";}';
29603
29604 // body max-width keeps charts from inflating beyond design dimensions on
29605 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
29606 // each chart's height blows up proportionally, breaking the one-page layout.
29607 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;}'+
29608 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
29609 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
29610 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
29611 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
29612 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
29613 'svg{display:block;}'+
29614 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
29615 '#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;}'+
29616 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29617 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
29618 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29619 '<div id="ox-tt"><\/div>'+
29620 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
29621 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
29622 '<div class="two-col">'+
29623 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
29624 '<div class="leg">'+
29625 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29626 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29627 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
29628 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
29629 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
29630 '<\/div>'+
29631 '<div class="two-col">'+
29632 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
29633 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
29634 '<\/div>'+
29635 '<script>'+ttJs+'<\/script>'+
29636 '<\/body><\/html>';
29637 slocDownload(html, fname, 'text/html;charset=utf-8;');
29638 }
29639 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
29640 window.buildDeltaChartsHtml = function() {
29641 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29642 var sd=_sd;
29643 var projEl=document.querySelector('[data-folder]');
29644 var proj=projEl?projEl.getAttribute('data-folder'):'';
29645 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
29646 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
29647 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
29648 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
29649 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";}';
29650 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);}';
29651 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29652 '<div id="ox-tt"><\/div>'+
29653 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
29654 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
29655 '<div class="two-col">'+
29656 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
29657 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29658 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29659 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
29660 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
29661 '<\/div>'+
29662 '<div class="two-col">'+
29663 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
29664 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
29665 '<\/div>'+
29666 '<script>'+ttJs+'<\/script>'+
29667 '<\/body><\/html>';
29668 };
29669 // ── Inline delta charts ────────────────────────────────────────────────────
29670 var _icTT=document.getElementById('ic-tt');
29671 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
29672 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';};
29673 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
29674 window.addEventListener('blur',function(){window.icHT();});
29675 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
29676 (function(){
29677 // Theme-aware palette — matches the canonical scheme used by /test-metrics
29678 // charts so every page renders bars/text/grid with the same colours and
29679 // adapts to dark mode (see Design section in CLAUDE.md).
29680 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
29681 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
29682 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
29683 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
29684 // washed) so the chart reads with the same weight as /test-metrics.
29685 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
29686 var FADE=dark?'#524238':'#e6d0bf';
29687 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
29688 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29689 function fmt(n){return Number(n).toLocaleString();}
29690 function px(n){return Math.round(n);}
29691 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29692 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
29693 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);});}
29694 var dr=getDeltaExportRows(),sd=_sd,lm={};
29695 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;});
29696 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29697 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
29698 // the bars are as tall as the (usually taller) Language Code Delta sibling that
29699 // shares the same grid row, instead of sitting short at the top.
29700 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:OXD,cc:OX},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:GND,cc:GN},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:GDD,cc:GD}];
29701 function drawC1(){
29702 var C1W=600,C1H=188;
29703 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
29704 if(host&&card&&host.clientWidth>0){
29705 var avW=host.clientWidth;
29706 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
29707 var wantH=availPx*C1W/avW;
29708 if(wantH>C1H)C1H=wantH;
29709 }
29710 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
29711 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29712 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"/>';}
29713 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
29714 c1mets.forEach(function(m,i){
29715 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29716 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
29717 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
29718 var gMax=Math.max(m.b,m.c)*1.15||1;
29719 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29720 c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
29721 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"/>';
29722 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+fmt(m.b)+'</text>';
29723 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"/>';
29724 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
29725 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">Before</text>';
29726 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">After</text>';
29727 });
29728 c1+='<text x="'+px(C1W/2)+'" y="'+px(C1H-6)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="8.5" fill="'+mutedCol+'">Each metric uses its own scale — compare Before vs After within a metric</text>';
29729 c1+='</svg>';
29730 return c1;
29731 }
29732 var c1=drawC1();
29733 // Chart 2: Delta by Metric
29734 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:OX},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:GN},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:GD}];
29735 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29736 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;
29737 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29738 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29739 mets.forEach(function(m,i){
29740 var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29741 c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
29742 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
29743 if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
29744 else{var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+textCol+'">'+esc(vStr)+'</text>';}
29745 });
29746 c2+='</svg>';
29747 // Chart 3: Language Code Delta
29748 var c3='';
29749 if(langs.length){
29750 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29751 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;
29752 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29753 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29754 langs.forEach(function(l,i){
29755 var e=lm[l],y=8+i*L3rH,bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29756 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
29757 c3+='<rect'+btt(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
29758 if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
29759 else{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}
29760 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
29761 });
29762 c3+='</svg>';
29763 }
29764 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
29765 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
29766 var segs=[{l:'Modified',v:sd.fm,c:OX},{l:'Added',v:sd.fa,c:GN},{l:'Removed',v:sd.fr,c:RD},{l:'Unchanged',v:sd.fu,c:FADE}].filter(function(s){return s.v>0;});
29767 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29768 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
29769 var legX=212,legCount=segs.length,legSpacing=Math.max(18,Math.min(30,Math.floor((DH-24)/Math.max(legCount,1)))),legYStart=Math.round((DH-legCount*legSpacing)/2);
29770 var c4='<svg viewBox="0 0 '+DW+' '+DH+'" width="100%" style="display:block;max-width:480px;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
29771 if(segs.length===1){
29772 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
29773 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files \u2022 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+rm+'" fill="none" stroke="'+segs[0].c+'" stroke-width="'+rsw+'"/>';
29774 } else {
29775 // Give every visible slice a small minimum sweep, taken from the largest
29776 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
29777 // arc whose start and end points coincide, so SVG renders nothing (blank).
29778 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
29779 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
29780 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
29781 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
29782 segs.forEach(function(s,si){
29783 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
29784 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);
29785 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);
29786 var pct=Math.round(s.v/tot*100);
29787 c4+='<path'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="'+surfCol+'" stroke-width="2"/>';
29788 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text x="'+px(cx4+mR*Math.cos(mAng))+'" y="'+px(cy4+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT4+'" font-size="11" font-weight="700" fill="'+(s.c===FADE?textCol:'#fff')+'" style="pointer-events:none;">'+pct+'%</text>';}
29789 ang+=sw;
29790 });
29791 }
29792 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
29793 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
29794 segs.forEach(function(s,i){
29795 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
29796 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
29797 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
29798 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
29799 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
29800 c4+='<text x="'+(legX+92)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(12,legSpacing-4)+'" font-weight="700" fill="'+mutedCol+'">'+fmt(s.v)+' ('+pct+'%)</text>';
29801 c4+='</g>';
29802 });
29803 c4+='</svg>';
29804 // Inject the fixed-height siblings first so the grid row settles to the (taller)
29805 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
29806 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
29807 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);}
29808 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
29809 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
29810 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
29811
29812 // Compare Timeline chart (Baseline vs Current, 2 points)
29813 (function() {
29814 var activeCmpMetric='code';
29815 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
29816 function renderCmpTL(metric, targetSvg, targetH) {
29817 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
29818 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
29819 svg.setAttribute('height',H);
29820 var pad={l:62,r:20,t:32,b:72};
29821 var dark=document.body.classList.contains('dark-theme');
29822 var cmpPts=[
29823 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
29824 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
29825 ];
29826 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
29827 var valid=pts.filter(function(v){return v!=null;});
29828 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;}
29829 var minV=0,maxV=Math.max.apply(null,valid);
29830 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
29831 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
29832 var cx0=pad.l,cx1=pad.l+plotW;
29833 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29834 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29835 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
29836 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
29837 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
29838 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();}
29839 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29840 var parts=[];
29841 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
29842 for(var gi=0;gi<5;gi++){
29843 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
29844 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
29845 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
29846 }
29847 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+'"/>');
29848 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"/>');
29849 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
29850 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
29851 dotPts.forEach(function(pt){
29852 parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(pt.cy-11).toFixed(1)+'" text-anchor="'+pt.anchor+'" font-size="11" font-weight="600" fill="'+textColor+'">'+Number(pt.v).toLocaleString()+'</text>');
29853 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"/>');
29854 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>');
29855 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>');
29856 });
29857 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
29858 svg.setAttribute('viewBox','0 0 '+W+' '+H);
29859 svg.innerHTML=parts.join('');
29860 // Hover: crosshair + tooltip (matches multi-scan timeline)
29861 var cmpTT=document.getElementById('ic-tt');
29862 svg.onmousemove=function(e){
29863 var rect=svg.getBoundingClientRect();
29864 var scaleX=W/rect.width;
29865 var mouseX=(e.clientX-rect.left)*scaleX;
29866 var nearest=-1,minDist=Infinity;
29867 var cxArr=[cx0,cx1];
29868 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;}}
29869 if(nearest<0)return;
29870 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
29871 var xhair=svg.querySelector('.cmp-xhair');
29872 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
29873 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"/>';
29874 if(!cmpTT)return;
29875 var clbl=cmpPts[nearest].label;
29876 var scanLbl=nearest===0?'Baseline':'Current';
29877 cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
29878 var bx=rect.left+(nc/W*rect.width)+18;
29879 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
29880 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
29881 };
29882 svg.onmouseleave=function(){
29883 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
29884 if(cmpTT)cmpTT.style.display='none';
29885 };
29886 }
29887 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
29888 btn.addEventListener('click',function(){
29889 activeCmpMetric=this.dataset.cmpMetric;
29890 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
29891 this.classList.add('active');
29892 renderCmpTL(activeCmpMetric);
29893 });
29894 });
29895 var ttgl=document.getElementById('theme-toggle');
29896 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
29897 if(typeof ResizeObserver!=='undefined'){
29898 var cmpSvg=document.getElementById('cmp-tl-svg');
29899 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
29900 }
29901 // Expose the timeline renderer + current metric so the Full View modal can
29902 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
29903 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
29904 window.__sdGetMetric=function(){return activeCmpMetric;};
29905 renderCmpTL(activeCmpMetric);
29906 })();
29907
29908 // HTML legend hover -> highlight matching SVG bars within the SAME card only
29909 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
29910 var metric=leg.getAttribute('data-highlight');
29911 var parentCard=leg.closest('.ic-card');
29912 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
29913 if(!chartEl)return;
29914 leg.addEventListener('mouseenter',function(){
29915 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
29916 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';}
29917 else{x.style.opacity='0.28';}
29918 });
29919 });
29920 leg.addEventListener('mouseleave',function(){
29921 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
29922 });
29923 });
29924
29925 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
29926 (function(){
29927 var ov=document.getElementById('ic-svg-modal-ov');
29928 var body=document.getElementById('ic-svg-modal-body');
29929 var ttl=document.getElementById('ic-svg-modal-title');
29930 var closeBtn=document.getElementById('ic-svg-modal-close');
29931 if(!ov||!body)return;
29932 function close(){
29933 ov.classList.remove('open');body.innerHTML='';
29934 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
29935 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
29936 }
29937 function open(srcId,title){
29938 var src=document.getElementById(srcId);if(!src)return;
29939 if(ttl)ttl.textContent=title||'';
29940 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
29941 // snapshot stretches and loses interactivity. Re-render it live into the modal at
29942 // full size instead — keeps proportions, animation, crosshair, tooltip and the
29943 // metric tabs working exactly like the inline chart.
29944 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
29945 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
29946 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
29947 var btnsHtml=mets.map(function(p){return '<button class="chart-metric-btn'+(p[0]===curM?' active':'')+'" data-fv-metric="'+p[0]+'">'+p[1]+'</button>';}).join('');
29948 body.innerHTML='<div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;">'+btnsHtml+'</div><div class="chart-wrap" style="width:100%;"><svg id="cmp-tl-fv-svg" width="100%" height="440" style="display:block;width:100%;"></svg></div>';
29949 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
29950 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
29951 ov.classList.add('open');
29952 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
29953 if(typeof ResizeObserver!=='undefined'){var ro=new ResizeObserver(function(){if(window.__sdFvTL)window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);});ro.observe(fvSvg);window.__sdFvTL.ro=ro;}
29954 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
29955 b.addEventListener('click',function(){
29956 if(!window.__sdFvTL)return;
29957 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
29958 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
29959 this.classList.add('active');
29960 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
29961 });
29962 });
29963 return;
29964 }
29965 var card=src.closest('.ic-card');
29966 var legHtml='';
29967 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
29968 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
29969 if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No chart data to display.</p>';ov.classList.add('open');return;}
29970 body.innerHTML=legHtml+inner;
29971 var svg=body.querySelector('svg');
29972 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
29973 addTT(body);
29974 ov.classList.add('open');
29975 }
29976 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
29977 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
29978 });
29979 if(closeBtn)closeBtn.addEventListener('click',close);
29980 ov.addEventListener('click',function(e){if(e.target===ov)close();});
29981 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
29982 })();
29983
29984 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
29985 })();
29986 </script>
29987 {{ toast_assets|safe }}
29988 <script nonce="{{ csp_nonce }}">
29989 (function(){
29990 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'}];
29991 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);});}
29992 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29993 function init(){
29994 var btn=document.getElementById('settings-btn');if(!btn)return;
29995 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29996 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>';
29997 document.body.appendChild(m);
29998 var g=document.getElementById('scheme-grid');
29999 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);});
30000 var cl=document.getElementById('settings-close');
30001 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);
30002 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');});
30003 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30004 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30005 }
30006 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30007 }());
30008 </script>
30009 <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]';
30010 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;}
30011 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>
30012</body>
30013</html>
30014"##,
30015 ext = "html"
30016)]
30017#[allow(clippy::struct_excessive_bools)]
30019struct CompareTemplate {
30020 loading_overlay: String,
30022 version: &'static str,
30023 project_label: String,
30024 baseline_git_commit: String,
30025 current_git_commit: String,
30026 baseline_run_id: String,
30027 current_run_id: String,
30028 baseline_run_id_short: String,
30029 current_run_id_short: String,
30030 baseline_timestamp: String,
30031 baseline_timestamp_utc_ms: i64,
30032 current_timestamp: String,
30033 current_timestamp_utc_ms: i64,
30034 project_path: String,
30035 baseline_code: u64,
30036 current_code: u64,
30037 code_lines_delta_str: String,
30038 code_lines_delta_class: String,
30039 baseline_files: u64,
30040 current_files: u64,
30041 files_analyzed_delta_str: String,
30042 files_analyzed_delta_class: String,
30043 baseline_comments: u64,
30044 current_comments: u64,
30045 comment_lines_delta_str: String,
30046 comment_lines_delta_class: String,
30047 baseline_code_fmt: String,
30048 current_code_fmt: String,
30049 baseline_files_fmt: String,
30050 current_files_fmt: String,
30051 baseline_comments_fmt: String,
30052 current_comments_fmt: String,
30053 code_lines_pct_str: String,
30054 files_analyzed_pct_str: String,
30055 comment_lines_pct_str: String,
30056 code_lines_added: i64,
30057 code_lines_removed: i64,
30058 new_scope: bool,
30060 churn_rate_str: String,
30061 churn_rate_class: String,
30062 scope_flag: bool,
30063 files_added: usize,
30064 files_removed: usize,
30065 files_modified: usize,
30066 files_unchanged: usize,
30067 file_rows: Vec<CompareFileDeltaRow>,
30068 baseline_git_author: Option<String>,
30069 current_git_author: Option<String>,
30070 baseline_git_branch: String,
30071 current_git_branch: String,
30072 baseline_git_tags: Option<String>,
30073 current_git_tags: Option<String>,
30074 baseline_git_commit_date: Option<String>,
30075 current_git_commit_date: Option<String>,
30076 project_name: String,
30077 submodule_options: Vec<String>,
30079 has_any_submodule_data: bool,
30081 active_submodule: Option<String>,
30083 super_scope_active: bool,
30085 csp_nonce: String,
30086 toast_assets: String,
30088 coverage_delta_card: String,
30090 baseline_test_count: u64,
30091 current_test_count: u64,
30092 baseline_coverage_pct: Option<f64>,
30093 current_coverage_pct: Option<f64>,
30094}
30095
30096#[derive(Template)]
30099#[template(
30100 source = r##"
30101<!doctype html>
30102<html lang="en">
30103<head>
30104 <meta charset="utf-8">
30105 <meta name="viewport" content="width=device-width, initial-scale=1">
30106 <title>OxideSLOC | Sign In</title>
30107 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30108 <style nonce="{{ csp_nonce }}">
30109 :root {
30110 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30111 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30112 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30113 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30114 }
30115 *{box-sizing:border-box;}
30116 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);}
30117 .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);}
30118 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30119 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30120 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30121 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30122 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30123 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30124 .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;}
30125 @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));}}
30126 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30127 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30128 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30129 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30130 .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;}
30131 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30132 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;}
30133 input[type=password]:focus{border-color:var(--oxide);}
30134 .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;}
30135 .btn:hover{opacity:.88;}
30136 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30137 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30138 </style>
30139</head>
30140<body>
30141 <div class="background-watermarks" aria-hidden="true">
30142 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30143 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30144 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30145 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30146 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30147 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30148 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30149 </div>
30150 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30151<nav class="top-nav">
30152 <a class="brand" href="/">
30153 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30154 <span class="brand-title">OxideSLOC</span>
30155 </a>
30156</nav>
30157<main class="page">
30158 <div class="card">
30159 <h1>Sign In</h1>
30160 <p class="subtitle">Enter the API key printed when the server started.</p>
30161 {% if has_error %}
30162 <div class="error">Incorrect API key — please try again.</div>
30163 {% endif %}
30164 <form method="POST" action="/auth/login">
30165 <input type="hidden" name="next" value="{{ next_url|e }}">
30166 <label for="key">API Key</label>
30167 <input id="key" type="password" name="key" autocomplete="current-password"
30168 placeholder="Paste your API key here" autofocus>
30169 <button type="submit" class="btn">Sign In</button>
30170 </form>
30171 <p class="hint">
30172 The API key was printed in the terminal when the server started.<br>
30173 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30174 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30175 </p>
30176 </div>
30177</main>
30178<script nonce="{{ csp_nonce }}">
30179(function() {
30180 (function randomizeWatermarks() {
30181 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30182 if (!wms.length) return;
30183 var placed = [];
30184 function tooClose(top, left) {
30185 for (var i = 0; i < placed.length; i++) {
30186 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30187 if (dt < 16 && dl < 12) return true;
30188 }
30189 return false;
30190 }
30191 function pick(leftBand) {
30192 for (var attempt = 0; attempt < 50; attempt++) {
30193 var top = Math.random() * 88 + 2;
30194 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30195 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30196 }
30197 var top = Math.random() * 88 + 2;
30198 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30199 placed.push([top, left]); return [top, left];
30200 }
30201 var half = Math.floor(wms.length / 2);
30202 wms.forEach(function (img, i) {
30203 var pos = pick(i < half);
30204 var size = Math.floor(Math.random() * 100 + 120);
30205 var rot = (Math.random() * 360).toFixed(1);
30206 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30207 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;
30208 });
30209 })();
30210 (function spawnCodeParticles() {
30211 var container = document.getElementById('code-particles');
30212 if (!container) return;
30213 var snippets = [
30214 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30215 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30216 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30217 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30218 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30219 ];
30220 var count = 38;
30221 for (var i = 0; i < count; i++) {
30222 (function(idx) {
30223 var el = document.createElement('span');
30224 el.className = 'code-particle';
30225 el.textContent = snippets[idx % snippets.length];
30226 var left = Math.random() * 94 + 2;
30227 var top = Math.random() * 88 + 6;
30228 var dur = (Math.random() * 10 + 9).toFixed(1);
30229 var delay = (Math.random() * 18).toFixed(1);
30230 var rot = (Math.random() * 26 - 13).toFixed(1);
30231 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30232 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30233 container.appendChild(el);
30234 })(i);
30235 }
30236 })();
30237})();
30238</script>
30239</body>
30240</html>
30241"##,
30242 ext = "html"
30243)]
30244pub(crate) struct LoginTemplate {
30245 pub(crate) csp_nonce: String,
30246 pub(crate) has_error: bool,
30247 pub(crate) next_url: String,
30248 pub(crate) lockout_threshold: u32,
30249}
30250
30251#[derive(Template)]
30254#[template(
30255 source = r##"
30256<!doctype html>
30257<html lang="en">
30258<head>
30259 <meta charset="utf-8">
30260 <meta name="viewport" content="width=device-width, initial-scale=1">
30261 <title>OxideSLOC — REST API Reference</title>
30262 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30263 <style nonce="{{ csp_nonce }}">
30264 :root {
30265 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30266 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30267 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30268 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30269 --success:#16a34a;
30270 }
30271 body.dark-theme {
30272 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30273 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30274 }
30275 *{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;}
30276 .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);}
30277 .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;}
30278 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30279 .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));}
30280 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30281 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30282 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30283 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30284 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30285 @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; } }
30286 .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;}
30287 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30288 .nav-pill.active{background:rgba(255,255,255,0.22);}
30289 .nav-dropdown{position:relative;display:inline-flex;}
30290 .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;}
30291 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30292 .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;}
30293 .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;}
30294 .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);}
30295 .nav-dropdown-menu a:last-child{border-bottom:none;}
30296 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30297 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30298 .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;}
30299 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30300 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30301 .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;}
30302 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30303 .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);}
30304 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30305 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30306 .settings-modal-body{padding:14px 16px 16px;}
30307 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30308 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30309 .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;}
30310 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30311 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30312 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30313 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30314 .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;}
30315 .tz-select:focus{border-color:var(--oxide);}
30316 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30317 .page-header{margin-bottom:28px;}
30318 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30319 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30320 .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;}
30321 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30322 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30323 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30324 .callout strong{font-weight:800;}
30325 .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;}
30326 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30327 .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;}
30328 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30329 .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;}
30330 body.dark-theme .base-url-value{color:var(--accent);}
30331 .section{margin-bottom:36px;}
30332 .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);}
30333 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30334 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30335 .ep-header:hover{background:var(--surface-2);}
30336 .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;}
30337 .method.get{background:#dcfce7;color:#166534;}
30338 .method.post{background:#dbeafe;color:#1e40af;}
30339 .method.delete{background:#fee2e2;color:#991b1b;}
30340 body.dark-theme .method.get{background:#14532d;color:#86efac;}
30341 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30342 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30343 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30344 .ep-path .param{color:var(--oxide-2);}
30345 body.dark-theme .ep-path .param{color:var(--oxide);}
30346 .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;}
30347 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30348 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30349 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30350 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30351 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30352 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30353 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30354 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30355 .ep-card.open .chevron{transform:rotate(180deg);}
30356 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30357 .ep-card.open .ep-body{display:block;}
30358 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30359 .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;}
30360 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30361 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30362 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30363 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30364 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);}
30365 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30366 table.params tr:last-child td{border-bottom:none;}
30367 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30368 .pt-type{color:var(--muted-2);font-size:12px;}
30369 .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;}
30370 .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;}
30371 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30372 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30373 details.schema{margin-bottom:14px;}
30374 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;}
30375 details.schema summary:hover{color:var(--text);}
30376 .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;}
30377 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30378 .curl-wrap{position:relative;}
30379 .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;}
30380 .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;}
30381 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30382 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30383 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30384 .webhook-note a{color:var(--accent-2);text-decoration:none;}
30385 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30386 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30387 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30388 .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;}
30389 @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));}}
30390 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30391 .site-footer a{color:var(--muted);}
30392 </style>
30393</head>
30394<body>
30395 <div class="background-watermarks" aria-hidden="true">
30396 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30397 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30398 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30399 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30400 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30401 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30402 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30403 </div>
30404 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30405 <div class="top-nav">
30406 <div class="top-nav-inner">
30407 <a class="brand" href="/">
30408 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30409 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30410 </a>
30411 <div class="nav-right">
30412 <a class="nav-pill" href="/">Home</a>
30413 <div class="nav-dropdown">
30414 <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>
30415 <div class="nav-dropdown-menu">
30416 <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>
30417 </div>
30418 </div>
30419 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30420 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30421 <div class="nav-dropdown">
30422 <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>
30423 <div class="nav-dropdown-menu">
30424 <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>
30425 </div>
30426 </div>
30427 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30428 <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>
30429 </button>
30430 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30431 <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>
30432 <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>
30433 </button>
30434 </div>
30435 </div>
30436 </div>
30437
30438 <div class="page">
30439 <div class="page-header">
30440 <h1 class="page-title">REST API Reference</h1>
30441 <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>
30442 </div>
30443
30444 {% if has_api_key %}
30445 <div class="callout key-set">
30446 <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>
30447 <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>
30448 </div>
30449 {% else %}
30450 <div class="callout no-key">
30451 <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>
30452 <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>
30453 </div>
30454 {% endif %}
30455
30456 <div class="base-url-bar">
30457 <span class="base-url-label">Base URL</span>
30458 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30459 </div>
30460
30461 <!-- Health -->
30462 <div class="section">
30463 <h2 class="section-title">Health & Status</h2>
30464 <div class="ep-card">
30465 <div class="ep-header">
30466 <span class="method get">GET</span>
30467 <span class="ep-path">/healthz</span>
30468 <span class="auth-badge public">Public</span>
30469 <span class="ep-desc">Server liveness check</span>
30470 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30471 </div>
30472 <div class="ep-body">
30473 <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>
30474 <p class="params-heading">Response</p>
30475 <div class="schema-block">200 OK
30476Content-Type: text/plain
30477
30478ok</div>
30479 <p class="curl-heading">Example</p>
30480 <div class="curl-wrap">
30481 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30482 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30483 </div>
30484 </div>
30485 </div>
30486 </div>
30487
30488 <!-- Badges -->
30489 <div class="section">
30490 <h2 class="section-title">Badges</h2>
30491 <div class="ep-card">
30492 <div class="ep-header">
30493 <span class="method get">GET</span>
30494 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30495 <span class="auth-badge public">Public</span>
30496 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30497 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30498 </div>
30499 <div class="ep-body">
30500 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30501 <p class="params-heading">Path Parameters</p>
30502 <table class="params">
30503 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30504 <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>
30505 </table>
30506 <p class="curl-heading">Example</p>
30507 <div class="curl-wrap">
30508 <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>
30509 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
30510 </div>
30511 </div>
30512 </div>
30513 </div>
30514
30515 <!-- Metrics -->
30516 <div class="section">
30517 <h2 class="section-title">Metrics</h2>
30518
30519 <div class="ep-card">
30520 <div class="ep-header">
30521 <span class="method get">GET</span>
30522 <span class="ep-path">/api/metrics/latest</span>
30523 <span class="auth-badge protected">Protected</span>
30524 <span class="ep-desc">Latest scan metrics (JSON)</span>
30525 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30526 </div>
30527 <div class="ep-body">
30528 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
30529 <details class="schema"><summary>Response schema</summary>
30530<div class="schema-block">{
30531 "run_id": string, // UUID
30532 "timestamp": string, // ISO-8601 UTC
30533 "project": string, // scanned root path
30534 "summary": {
30535 "files_analyzed": number,
30536 "files_skipped": number,
30537 "code_lines": number,
30538 "comment_lines": number,
30539 "blank_lines": number,
30540 "total_physical_lines": number,
30541 "functions": number,
30542 "classes": number,
30543 "variables": number,
30544 "imports": number
30545 },
30546 "languages": [
30547 { "name": string, "files": number, "code_lines": number,
30548 "comment_lines": number, "blank_lines": number,
30549 "functions": number, "classes": number,
30550 "variables": number, "imports": number }
30551 ]
30552}</div></details>
30553 <p class="curl-heading">Example</p>
30554 <div class="curl-wrap">
30555 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30556 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
30557 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
30558 </div>
30559 </div>
30560 </div>
30561
30562 <div class="ep-card">
30563 <div class="ep-header">
30564 <span class="method get">GET</span>
30565 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
30566 <span class="auth-badge protected">Protected</span>
30567 <span class="ep-desc">Metrics for a specific run</span>
30568 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30569 </div>
30570 <div class="ep-body">
30571 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
30572 <p class="params-heading">Path Parameters</p>
30573 <table class="params">
30574 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30575 <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>
30576 </table>
30577 <p class="curl-heading">Example</p>
30578 <div class="curl-wrap">
30579 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30580 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
30581 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
30582 </div>
30583 </div>
30584 </div>
30585
30586 <div class="ep-card">
30587 <div class="ep-header">
30588 <span class="method get">GET</span>
30589 <span class="ep-path">/api/metrics/history</span>
30590 <span class="auth-badge protected">Protected</span>
30591 <span class="ep-desc">Paginated scan history</span>
30592 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30593 </div>
30594 <div class="ep-body">
30595 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
30596 <p class="params-heading">Query Parameters</p>
30597 <table class="params">
30598 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30599 <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>
30600 <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>
30601 </table>
30602 <details class="schema"><summary>Response schema</summary>
30603<div class="schema-block">[{
30604 "run_id": string,
30605 "timestamp": string, // ISO-8601 UTC
30606 "commit": string | null,
30607 "branch": string | null,
30608 "tags": string[],
30609 "code_lines": number,
30610 "comment_lines": number,
30611 "blank_lines": number,
30612 "physical_lines": number,
30613 "files_analyzed": number,
30614 "project_label": string,
30615 "html_url": string | null
30616}]</div></details>
30617 <p class="curl-heading">Example</p>
30618 <div class="curl-wrap">
30619 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30620 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
30621 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
30622 </div>
30623 </div>
30624 </div>
30625
30626 <div class="ep-card">
30627 <div class="ep-header">
30628 <span class="method get">GET</span>
30629 <span class="ep-path">/api/project-history</span>
30630 <span class="auth-badge protected">Protected</span>
30631 <span class="ep-desc">Project-level scan summary</span>
30632 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30633 </div>
30634 <div class="ep-body">
30635 <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>
30636 <p class="params-heading">Query Parameters</p>
30637 <table class="params">
30638 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30639 <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>
30640 </table>
30641 <details class="schema"><summary>Response schema</summary>
30642<div class="schema-block">{
30643 "scan_count": number,
30644 "last_scan_id": string | null,
30645 "last_scan_timestamp": string | null, // ISO-8601
30646 "last_scan_code_lines": number | null,
30647 "last_git_branch": string | null,
30648 "last_git_commit": string | null
30649}</div></details>
30650 <p class="curl-heading">Example</p>
30651 <div class="curl-wrap">
30652 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30653 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
30654 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
30655 </div>
30656 </div>
30657 </div>
30658
30659 <div class="ep-card">
30660 <div class="ep-header">
30661 <span class="method get">GET</span>
30662 <span class="ep-path">/api/metrics/submodules</span>
30663 <span class="auth-badge protected">Protected</span>
30664 <span class="ep-desc">List known git submodules across scans</span>
30665 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30666 </div>
30667 <div class="ep-body">
30668 <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>
30669 <p class="params-heading">Query Parameters</p>
30670 <table class="params">
30671 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30672 <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>
30673 </table>
30674 <details class="schema"><summary>Response schema</summary>
30675<div class="schema-block">[{
30676 "name": string, // submodule name
30677 "relative_path": string // path relative to the project root
30678}]</div></details>
30679 <p class="curl-heading">Example</p>
30680 <div class="curl-wrap">
30681 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30682 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
30683 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
30684 </div>
30685 </div>
30686 </div>
30687 </div>
30688
30689 <!-- Async Run Status -->
30690 <div class="section">
30691 <h2 class="section-title">Async Run Status</h2>
30692
30693 <div class="ep-card">
30694 <div class="ep-header">
30695 <span class="method get">GET</span>
30696 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
30697 <span class="auth-badge protected">Protected</span>
30698 <span class="ep-desc">Poll scan completion</span>
30699 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30700 </div>
30701 <div class="ep-body">
30702 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
30703 <details class="schema"><summary>Response schema</summary>
30704<div class="schema-block">// Running
30705{ "state": "running", "elapsed_secs": number }
30706
30707// Complete
30708{ "state": "complete", "run_id": string }
30709
30710// Failed
30711{ "state": "failed", "message": string }</div></details>
30712 <p class="curl-heading">Example</p>
30713 <div class="curl-wrap">
30714 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30715 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
30716 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
30717 </div>
30718 </div>
30719 </div>
30720
30721 <div class="ep-card">
30722 <div class="ep-header">
30723 <span class="method get">GET</span>
30724 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
30725 <span class="auth-badge protected">Protected</span>
30726 <span class="ep-desc">Poll PDF generation readiness</span>
30727 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30728 </div>
30729 <div class="ep-body">
30730 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
30731 <details class="schema"><summary>Response schema</summary>
30732<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
30733 <p class="curl-heading">Example</p>
30734 <div class="curl-wrap">
30735 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30736 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
30737 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
30738 </div>
30739 </div>
30740 </div>
30741
30742 <div class="ep-card">
30743 <div class="ep-header">
30744 <span class="method post">POST</span>
30745 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
30746 <span class="auth-badge protected">Protected</span>
30747 <span class="ep-desc">Cancel a running scan</span>
30748 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30749 </div>
30750 <div class="ep-body">
30751 <p class="ep-desc-full">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>
30752 <p class="curl-heading">Example</p>
30753 <div class="curl-wrap">
30754 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
30755 -H "Authorization: Bearer $SLOC_API_KEY" \
30756 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
30757 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
30758 </div>
30759 </div>
30760 </div>
30761 </div>
30762
30763 <!-- Run Management -->
30764 <div class="section">
30765 <h2 class="section-title">Run Management</h2>
30766
30767 <div class="ep-card">
30768 <div class="ep-header">
30769 <span class="method get">GET</span>
30770 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
30771 <span class="auth-badge protected">Protected</span>
30772 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
30773 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30774 </div>
30775 <div class="ep-body">
30776 <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>
30777 <p class="params-heading">Path Parameters</p>
30778 <table class="params">
30779 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30780 <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>
30781 </table>
30782 <details class="schema"><summary>Response</summary>
30783<div class="schema-block">200 OK — Content-Type: application/zip
30784Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
30785
30786404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
30787 <p class="curl-heading">Example</p>
30788 <div class="curl-wrap">
30789 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30790 -o run.zip \
30791 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
30792 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
30793 </div>
30794 </div>
30795 </div>
30796
30797 <div class="ep-card">
30798 <div class="ep-header">
30799 <span class="method delete">DELETE</span>
30800 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
30801 <span class="auth-badge protected">Protected</span>
30802 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
30803 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30804 </div>
30805 <div class="ep-body">
30806 <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>
30807 <p class="params-heading">Path Parameters</p>
30808 <table class="params">
30809 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30810 <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>
30811 </table>
30812 <details class="schema"><summary>Response</summary>
30813<div class="schema-block">204 No Content — run successfully deleted
30814
30815500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
30816 <p class="curl-heading">Example</p>
30817 <div class="curl-wrap">
30818 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
30819 -H "Authorization: Bearer $SLOC_API_KEY" \
30820 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
30821 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
30822 </div>
30823 </div>
30824 </div>
30825
30826 <div class="ep-card">
30827 <div class="ep-header">
30828 <span class="method post">POST</span>
30829 <span class="ep-path">/api/runs/cleanup</span>
30830 <span class="auth-badge protected">Protected</span>
30831 <span class="ep-desc">Bulk delete runs older than N days</span>
30832 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30833 </div>
30834 <div class="ep-body">
30835 <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>
30836 <p class="params-heading">Request Body (application/json)</p>
30837 <table class="params">
30838 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30839 <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>
30840 </table>
30841 <details class="schema"><summary>Response schema</summary>
30842<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
30843 <p class="curl-heading">Example — delete runs older than 60 days</p>
30844 <div class="curl-wrap">
30845 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
30846 -H "Authorization: Bearer $SLOC_API_KEY" \
30847 -H "Content-Type: application/json" \
30848 -d '{"older_than_days":60}' \
30849 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
30850 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
30851 </div>
30852 </div>
30853 </div>
30854 </div>
30855
30856 <!-- Retention Policy -->
30857 <div class="section">
30858 <h2 class="section-title">Retention Policy</h2>
30859
30860 <div class="ep-card">
30861 <div class="ep-header">
30862 <span class="method get">GET</span>
30863 <span class="ep-path">/api/cleanup-policy</span>
30864 <span class="auth-badge protected">Protected</span>
30865 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
30866 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30867 </div>
30868 <div class="ep-body">
30869 <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>
30870 <details class="schema"><summary>Response schema</summary>
30871<div class="schema-block">{
30872 "policy": {
30873 "enabled": boolean,
30874 "max_age_days": number | null, // delete runs older than N days
30875 "max_run_count": number | null, // keep only the N most recent runs
30876 "interval_hours": number // hours between background passes
30877 } | null,
30878 "last_run_at": string | null, // ISO-8601 UTC timestamp
30879 "last_run_deleted": number | null // runs deleted in last pass
30880}</div></details>
30881 <p class="curl-heading">Example</p>
30882 <div class="curl-wrap">
30883 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30884 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30885 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
30886 </div>
30887 </div>
30888 </div>
30889
30890 <div class="ep-card">
30891 <div class="ep-header">
30892 <span class="method post">POST</span>
30893 <span class="ep-path">/api/cleanup-policy</span>
30894 <span class="auth-badge protected">Protected</span>
30895 <span class="ep-desc">Save or update the retention policy</span>
30896 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30897 </div>
30898 <div class="ep-body">
30899 <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>
30900 <p class="params-heading">Request Body (application/json)</p>
30901 <table class="params">
30902 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30903 <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>
30904 <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>
30905 <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>
30906 <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>
30907 </table>
30908 <details class="schema"><summary>Response</summary>
30909<div class="schema-block">204 No Content — policy saved and task (re)started
30910
30911500 Internal Server Error — { "error": string }</div></details>
30912 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
30913 <div class="curl-wrap">
30914 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
30915 -H "Authorization: Bearer $SLOC_API_KEY" \
30916 -H "Content-Type: application/json" \
30917 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
30918 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30919 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
30920 </div>
30921 </div>
30922 </div>
30923
30924 <div class="ep-card">
30925 <div class="ep-header">
30926 <span class="method post">POST</span>
30927 <span class="ep-path">/api/cleanup-policy/run-now</span>
30928 <span class="auth-badge protected">Protected</span>
30929 <span class="ep-desc">Trigger an immediate cleanup pass</span>
30930 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30931 </div>
30932 <div class="ep-body">
30933 <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>
30934 <details class="schema"><summary>Response schema</summary>
30935<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
30936 <p class="curl-heading">Example</p>
30937 <div class="curl-wrap">
30938 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
30939 -H "Authorization: Bearer $SLOC_API_KEY" \
30940 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
30941 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
30942 </div>
30943 </div>
30944 </div>
30945
30946 <div class="ep-card">
30947 <div class="ep-header">
30948 <span class="method delete">DELETE</span>
30949 <span class="ep-path">/api/cleanup-policy</span>
30950 <span class="auth-badge protected">Protected</span>
30951 <span class="ep-desc">Remove the retention policy and stop the background task</span>
30952 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30953 </div>
30954 <div class="ep-body">
30955 <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>
30956 <details class="schema"><summary>Response</summary>
30957<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
30958 <p class="curl-heading">Example</p>
30959 <div class="curl-wrap">
30960 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
30961 -H "Authorization: Bearer $SLOC_API_KEY" \
30962 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30963 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
30964 </div>
30965 </div>
30966 </div>
30967 </div>
30968
30969 <!-- Scan Profiles -->
30970 <div class="section">
30971 <h2 class="section-title">Scan Profiles</h2>
30972
30973 <div class="ep-card">
30974 <div class="ep-header">
30975 <span class="method get">GET</span>
30976 <span class="ep-path">/api/scan-profiles</span>
30977 <span class="auth-badge protected">Protected</span>
30978 <span class="ep-desc">List saved scan profiles</span>
30979 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30980 </div>
30981 <div class="ep-body">
30982 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
30983 <details class="schema"><summary>Response schema</summary>
30984<div class="schema-block">{
30985 "profiles": [{
30986 "id": string, // UUID
30987 "name": string,
30988 "created_at": string, // ISO-8601
30989 "params": object
30990 }]
30991}</div></details>
30992 <p class="curl-heading">Example</p>
30993 <div class="curl-wrap">
30994 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30995 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
30996 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
30997 </div>
30998 </div>
30999 </div>
31000
31001 <div class="ep-card">
31002 <div class="ep-header">
31003 <span class="method post">POST</span>
31004 <span class="ep-path">/api/scan-profiles</span>
31005 <span class="auth-badge protected">Protected</span>
31006 <span class="ep-desc">Save a scan profile</span>
31007 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31008 </div>
31009 <div class="ep-body">
31010 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31011 <p class="params-heading">Request Body (application/json)</p>
31012 <table class="params">
31013 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31014 <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>
31015 <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>
31016 </table>
31017 <details class="schema"><summary>Response schema</summary>
31018<div class="schema-block">{ "ok": true }</div></details>
31019 <p class="curl-heading">Example</p>
31020 <div class="curl-wrap">
31021 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31022 -H "Authorization: Bearer $SLOC_API_KEY" \
31023 -H "Content-Type: application/json" \
31024 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31025 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31026 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31027 </div>
31028 </div>
31029 </div>
31030
31031 <div class="ep-card">
31032 <div class="ep-header">
31033 <span class="method delete">DELETE</span>
31034 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31035 <span class="auth-badge protected">Protected</span>
31036 <span class="ep-desc">Delete a scan profile</span>
31037 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31038 </div>
31039 <div class="ep-body">
31040 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31041 <p class="params-heading">Path Parameters</p>
31042 <table class="params">
31043 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31044 <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>
31045 </table>
31046 <details class="schema"><summary>Response schema</summary>
31047<div class="schema-block">{ "ok": true }</div></details>
31048 <p class="curl-heading">Example</p>
31049 <div class="curl-wrap">
31050 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31051 -H "Authorization: Bearer $SLOC_API_KEY" \
31052 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31053 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31054 </div>
31055 </div>
31056 </div>
31057 </div>
31058
31059 <!-- Scheduled Scans -->
31060 <div class="section">
31061 <h2 class="section-title">Scheduled Scans</h2>
31062
31063 <div class="ep-card">
31064 <div class="ep-header">
31065 <span class="method get">GET</span>
31066 <span class="ep-path">/api/schedules</span>
31067 <span class="auth-badge protected">Protected</span>
31068 <span class="ep-desc">List configured schedules</span>
31069 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31070 </div>
31071 <div class="ep-body">
31072 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31073 <p class="curl-heading">Example</p>
31074 <div class="curl-wrap">
31075 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31076 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31077 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31078 </div>
31079 </div>
31080 </div>
31081
31082 <div class="ep-card">
31083 <div class="ep-header">
31084 <span class="method post">POST</span>
31085 <span class="ep-path">/api/schedules</span>
31086 <span class="auth-badge protected">Protected</span>
31087 <span class="ep-desc">Create a schedule</span>
31088 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31089 </div>
31090 <div class="ep-body">
31091 <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>
31092 <p class="curl-heading">Example</p>
31093 <div class="curl-wrap">
31094 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31095 -H "Authorization: Bearer $SLOC_API_KEY" \
31096 -H "Content-Type: application/json" \
31097 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31098 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31099 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31100 </div>
31101 </div>
31102 </div>
31103
31104 <div class="ep-card">
31105 <div class="ep-header">
31106 <span class="method delete">DELETE</span>
31107 <span class="ep-path">/api/schedules</span>
31108 <span class="auth-badge protected">Protected</span>
31109 <span class="ep-desc">Delete a schedule</span>
31110 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31111 </div>
31112 <div class="ep-body">
31113 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31114 <p class="curl-heading">Example</p>
31115 <div class="curl-wrap">
31116 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31117 -H "Authorization: Bearer $SLOC_API_KEY" \
31118 -H "Content-Type: application/json" \
31119 -d '{"id":"<schedule_id>"}' \
31120 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31121 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31122 </div>
31123 </div>
31124 </div>
31125 </div>
31126
31127 <!-- Git Browser -->
31128 <div class="section">
31129 <h2 class="section-title">Git Browser</h2>
31130
31131 <div class="ep-card">
31132 <div class="ep-header">
31133 <span class="method get">GET</span>
31134 <span class="ep-path">/api/git/refs</span>
31135 <span class="auth-badge protected">Protected</span>
31136 <span class="ep-desc">List git refs for a repository</span>
31137 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31138 </div>
31139 <div class="ep-body">
31140 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31141 <p class="params-heading">Query Parameters</p>
31142 <table class="params">
31143 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31144 <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>
31145 </table>
31146 <p class="curl-heading">Example</p>
31147 <div class="curl-wrap">
31148 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31149 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31150 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31151 </div>
31152 </div>
31153 </div>
31154
31155 <div class="ep-card">
31156 <div class="ep-header">
31157 <span class="method get">GET</span>
31158 <span class="ep-path">/api/git/scan-ref</span>
31159 <span class="auth-badge protected">Protected</span>
31160 <span class="ep-desc">SLOC-scan a specific git ref</span>
31161 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31162 </div>
31163 <div class="ep-body">
31164 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31165 <p class="params-heading">Query Parameters</p>
31166 <table class="params">
31167 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31168 <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>
31169 <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>
31170 </table>
31171 <p class="curl-heading">Example</p>
31172 <div class="curl-wrap">
31173 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31174 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
31175 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31176 </div>
31177 </div>
31178 </div>
31179
31180 <div class="ep-card">
31181 <div class="ep-header">
31182 <span class="method get">GET</span>
31183 <span class="ep-path">/api/git/compare-refs</span>
31184 <span class="auth-badge protected">Protected</span>
31185 <span class="ep-desc">Compare SLOC across two git refs</span>
31186 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31187 </div>
31188 <div class="ep-body">
31189 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31190 <p class="params-heading">Query Parameters</p>
31191 <table class="params">
31192 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31193 <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>
31194 <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>
31195 <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>
31196 </table>
31197 <p class="curl-heading">Example</p>
31198 <div class="curl-wrap">
31199 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31200 "<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>
31201 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31202 </div>
31203 </div>
31204 </div>
31205 </div>
31206
31207 <!-- Webhooks -->
31208 <div class="section">
31209 <h2 class="section-title">Webhooks</h2>
31210 <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>
31211
31212 <div class="ep-card">
31213 <div class="ep-header">
31214 <span class="method post">POST</span>
31215 <span class="ep-path">/webhooks/github</span>
31216 <span class="auth-badge hmac">HMAC</span>
31217 <span class="ep-desc">GitHub push event receiver</span>
31218 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31219 </div>
31220 <div class="ep-body">
31221 <p class="ep-desc-full">Receives GitHub <code>push</code> events and triggers an SLOC scan. Authenticated via <code>X-Hub-Signature-256</code> HMAC-SHA256.</p>
31222 <p class="params-heading">Required Headers</p>
31223 <table class="params">
31224 <tr><th>Header</th><th>Value</th></tr>
31225 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31226 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31227 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31228 </table>
31229 </div>
31230 </div>
31231
31232 <div class="ep-card">
31233 <div class="ep-header">
31234 <span class="method post">POST</span>
31235 <span class="ep-path">/webhooks/gitlab</span>
31236 <span class="auth-badge hmac">HMAC</span>
31237 <span class="ep-desc">GitLab push event receiver</span>
31238 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31239 </div>
31240 <div class="ep-body">
31241 <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>
31242 <p class="params-heading">Required Headers</p>
31243 <table class="params">
31244 <tr><th>Header</th><th>Value</th></tr>
31245 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31246 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31247 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31248 </table>
31249 </div>
31250 </div>
31251
31252 <div class="ep-card">
31253 <div class="ep-header">
31254 <span class="method post">POST</span>
31255 <span class="ep-path">/webhooks/bitbucket</span>
31256 <span class="auth-badge hmac">HMAC</span>
31257 <span class="ep-desc">Bitbucket push event receiver</span>
31258 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31259 </div>
31260 <div class="ep-body">
31261 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31262 <p class="params-heading">Required Headers</p>
31263 <table class="params">
31264 <tr><th>Header</th><th>Value</th></tr>
31265 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31266 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31267 </table>
31268 </div>
31269 </div>
31270 </div>
31271
31272 <!-- Config -->
31273 <div class="section">
31274 <h2 class="section-title">Config Import / Export</h2>
31275
31276 <div class="ep-card">
31277 <div class="ep-header">
31278 <span class="method get">GET</span>
31279 <span class="ep-path">/export-config</span>
31280 <span class="auth-badge protected">Protected</span>
31281 <span class="ep-desc">Export server configuration as JSON</span>
31282 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31283 </div>
31284 <div class="ep-body">
31285 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31286 <p class="curl-heading">Example</p>
31287 <div class="curl-wrap">
31288 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31289 -o config.json \
31290 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31291 <button class="curl-copy-btn" data-target="c-export">Copy</button>
31292 </div>
31293 </div>
31294 </div>
31295
31296 <div class="ep-card">
31297 <div class="ep-header">
31298 <span class="method post">POST</span>
31299 <span class="ep-path">/import-config</span>
31300 <span class="auth-badge protected">Protected</span>
31301 <span class="ep-desc">Import server configuration</span>
31302 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31303 </div>
31304 <div class="ep-body">
31305 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31306 <p class="curl-heading">Example</p>
31307 <div class="curl-wrap">
31308 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31309 -H "Authorization: Bearer $SLOC_API_KEY" \
31310 -H "Content-Type: application/json" \
31311 -d @config.json \
31312 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31313 <button class="curl-copy-btn" data-target="c-import">Copy</button>
31314 </div>
31315 </div>
31316 </div>
31317 </div>
31318
31319 <!-- CI Ingest -->
31320 <div class="section">
31321 <h2 class="section-title">CI Ingest</h2>
31322
31323 <div class="ep-card">
31324 <div class="ep-header">
31325 <span class="method post">POST</span>
31326 <span class="ep-path">/api/ingest</span>
31327 <span class="auth-badge protected">Protected</span>
31328 <span class="ep-desc">Push a pre-computed scan result from CI</span>
31329 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31330 </div>
31331 <div class="ep-body">
31332 <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>
31333 <p class="params-heading">Query Parameters</p>
31334 <table class="params">
31335 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31336 <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>
31337 </table>
31338 <p class="params-heading">Request Body (application/json)</p>
31339 <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>
31340 <details class="schema"><summary>Response schema</summary>
31341<div class="schema-block">// 201 Created
31342{
31343 "run_id": string, // UUID of the ingested run
31344 "view_url": string // relative URL to the report page
31345}</div></details>
31346 <p class="curl-heading">Example</p>
31347 <div class="curl-wrap">
31348 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31349 -H "Authorization: Bearer $SLOC_API_KEY" \
31350 -H "Content-Type: application/json" \
31351 -d @result.json \
31352 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31353 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31354 </div>
31355 </div>
31356 </div>
31357 </div>
31358
31359 <!-- Artifact Download -->
31360 <div class="section">
31361 <h2 class="section-title">Artifact Download</h2>
31362
31363 <div class="ep-card">
31364 <div class="ep-header">
31365 <span class="method get">GET</span>
31366 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31367 <span class="auth-badge protected">Protected</span>
31368 <span class="ep-desc">Download or view a scan artifact</span>
31369 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31370 </div>
31371 <div class="ep-body">
31372 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31373 <p class="params-heading">Path Parameters</p>
31374 <table class="params">
31375 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31376 <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>
31377 <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>
31378 </table>
31379 <p class="params-heading">Query Parameters</p>
31380 <table class="params">
31381 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31382 <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>
31383 </table>
31384 <p class="curl-heading">Example — download JSON result</p>
31385 <div class="curl-wrap">
31386 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31387 -o result.json \
31388 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
31389 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31390 </div>
31391 </div>
31392 </div>
31393 </div>
31394
31395 <!-- Embed Widget -->
31396 <div class="section">
31397 <h2 class="section-title">Embed Widget</h2>
31398
31399 <div class="ep-card">
31400 <div class="ep-header">
31401 <span class="method get">GET</span>
31402 <span class="ep-path">/embed/summary</span>
31403 <span class="auth-badge protected">Protected</span>
31404 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31405 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31406 </div>
31407 <div class="ep-body">
31408 <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>
31409 <p class="params-heading">Query Parameters</p>
31410 <table class="params">
31411 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31412 <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>
31413 <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>
31414 </table>
31415 <p class="curl-heading">Example</p>
31416 <div class="curl-wrap">
31417 <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"
31418 width="460" height="260" style="border:none"></iframe></pre>
31419 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31420 </div>
31421 </div>
31422 </div>
31423 </div>
31424
31425 <!-- Confluence Integration -->
31426 <div class="section">
31427 <h2 class="section-title">Confluence Integration</h2>
31428
31429 <div class="ep-card">
31430 <div class="ep-header">
31431 <span class="method get">GET</span>
31432 <span class="ep-path">/api/confluence/config</span>
31433 <span class="auth-badge protected">Protected</span>
31434 <span class="ep-desc">Get current Confluence configuration</span>
31435 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31436 </div>
31437 <div class="ep-body">
31438 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31439 <details class="schema"><summary>Response schema</summary>
31440<div class="schema-block">{
31441 "configured": boolean,
31442 "tier": "cloud" | "server",
31443 "base_url": string,
31444 "username": string,
31445 "api_token_set": boolean,
31446 "space_key": string,
31447 "parent_page_id": string | null,
31448 "schedule_auto_post": { "<schedule_id>": boolean }
31449}</div></details>
31450 <p class="curl-heading">Example</p>
31451 <div class="curl-wrap">
31452 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31453 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31454 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31455 </div>
31456 </div>
31457 </div>
31458
31459 <div class="ep-card">
31460 <div class="ep-header">
31461 <span class="method post">POST</span>
31462 <span class="ep-path">/api/confluence/config</span>
31463 <span class="auth-badge protected">Protected</span>
31464 <span class="ep-desc">Save Confluence configuration</span>
31465 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31466 </div>
31467 <div class="ep-body">
31468 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31469 <p class="params-heading">Request Body (application/json)</p>
31470 <table class="params">
31471 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31472 <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>
31473 <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>
31474 <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>
31475 <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>
31476 <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>
31477 <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>
31478 <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>
31479 </table>
31480 <details class="schema"><summary>Response schema</summary>
31481<div class="schema-block">{ "ok": true }</div></details>
31482 <p class="curl-heading">Example</p>
31483 <div class="curl-wrap">
31484 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31485 -H "Authorization: Bearer $SLOC_API_KEY" \
31486 -H "Content-Type: application/json" \
31487 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31488 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31489 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31490 </div>
31491 </div>
31492 </div>
31493
31494 <div class="ep-card">
31495 <div class="ep-header">
31496 <span class="method post">POST</span>
31497 <span class="ep-path">/api/confluence/test</span>
31498 <span class="auth-badge protected">Protected</span>
31499 <span class="ep-desc">Test Confluence connection</span>
31500 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31501 </div>
31502 <div class="ep-body">
31503 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31504 <details class="schema"><summary>Response schema</summary>
31505<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
31506 <p class="curl-heading">Example</p>
31507 <div class="curl-wrap">
31508 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
31509 -H "Authorization: Bearer $SLOC_API_KEY" \
31510 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
31511 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
31512 </div>
31513 </div>
31514 </div>
31515
31516 <div class="ep-card">
31517 <div class="ep-header">
31518 <span class="method post">POST</span>
31519 <span class="ep-path">/api/confluence/post</span>
31520 <span class="auth-badge protected">Protected</span>
31521 <span class="ep-desc">Publish a scan report to Confluence</span>
31522 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31523 </div>
31524 <div class="ep-body">
31525 <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>
31526 <p class="params-heading">Request Body (application/json)</p>
31527 <table class="params">
31528 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31529 <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>
31530 <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>
31531 <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>
31532 </table>
31533 <details class="schema"><summary>Response schema</summary>
31534<div class="schema-block">// 200 OK
31535{ "ok": true, "page_id": string }
31536
31537// 400 / 502 on error
31538{ "ok": false, "error": string }</div></details>
31539 <p class="curl-heading">Example</p>
31540 <div class="curl-wrap">
31541 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
31542 -H "Authorization: Bearer $SLOC_API_KEY" \
31543 -H "Content-Type: application/json" \
31544 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
31545 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
31546 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
31547 </div>
31548 </div>
31549 </div>
31550
31551 <div class="ep-card">
31552 <div class="ep-header">
31553 <span class="method get">GET</span>
31554 <span class="ep-path">/api/confluence/wiki-markup</span>
31555 <span class="auth-badge protected">Protected</span>
31556 <span class="ep-desc">Get Confluence wiki markup for a run</span>
31557 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31558 </div>
31559 <div class="ep-body">
31560 <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>
31561 <p class="params-heading">Query Parameters</p>
31562 <table class="params">
31563 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31564 <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>
31565 </table>
31566 <p class="curl-heading">Example</p>
31567 <div class="curl-wrap">
31568 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31569 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
31570 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
31571 </div>
31572 </div>
31573 </div>
31574 </div>
31575
31576 <!-- Authentication -->
31577 <div class="section">
31578 <h2 class="section-title">Authentication</h2>
31579 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
31580
31581 <div class="ep-card">
31582 <div class="ep-header">
31583 <span class="method get">GET</span>
31584 <span class="ep-path">/auth/login</span>
31585 <span class="auth-badge public">Public</span>
31586 <span class="ep-desc">Login page</span>
31587 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31588 </div>
31589 <div class="ep-body">
31590 <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>
31591 <p class="params-heading">Query Parameters</p>
31592 <table class="params">
31593 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31594 <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>
31595 <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>
31596 </table>
31597 </div>
31598 </div>
31599
31600 <div class="ep-card">
31601 <div class="ep-header">
31602 <span class="method post">POST</span>
31603 <span class="ep-path">/auth/login</span>
31604 <span class="auth-badge public">Public</span>
31605 <span class="ep-desc">Submit credentials and get a session cookie</span>
31606 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31607 </div>
31608 <div class="ep-body">
31609 <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>
31610 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
31611 <table class="params">
31612 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31613 <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>
31614 <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>
31615 </table>
31616 <p class="curl-heading">Example</p>
31617 <div class="curl-wrap">
31618 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
31619 -d "key=$SLOC_API_KEY&next=/" \
31620 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
31621 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
31622 </div>
31623 </div>
31624 </div>
31625 </div>
31626
31627 <!-- Coverage Suggestion -->
31628 <div class="section">
31629 <h2 class="section-title">Coverage Suggestion</h2>
31630
31631 <div class="ep-card">
31632 <div class="ep-header">
31633 <span class="method get">GET</span>
31634 <span class="ep-path">/api/suggest-coverage</span>
31635 <span class="auth-badge protected">Protected</span>
31636 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
31637 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31638 </div>
31639 <div class="ep-body">
31640 <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML, coverage.py JSON) and returns the first one found, along with a hint for how to generate it if not present.</p>
31641 <p class="params-heading">Query Parameters</p>
31642 <table class="params">
31643 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31644 <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>
31645 </table>
31646 <details class="schema"><summary>Response schema</summary>
31647<div class="schema-block">{
31648 "found": string | null, // absolute path to the coverage file, if detected
31649 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
31650 "hint": string | null // shell command to generate coverage if not found
31651}</div></details>
31652 <p class="curl-heading">Example</p>
31653 <div class="curl-wrap">
31654 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31655 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
31656 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
31657 </div>
31658 </div>
31659 </div>
31660 </div>
31661
31662 </div>
31663
31664 <footer class="site-footer">
31665 local code analysis - metrics, history and reports
31666 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
31667 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31668 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31669 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31670 · <a href="/api-docs" rel="noopener">REST API</a>
31671 </footer>
31672
31673 <script nonce="{{ csp_nonce }}">
31674 (function () {
31675 var base = window.location.origin;
31676 document.getElementById('base-url').textContent = base;
31677 document.querySelectorAll('.base-url-slot').forEach(function (el) {
31678 el.textContent = base;
31679 });
31680
31681 document.querySelectorAll('.ep-header').forEach(function (hdr) {
31682 hdr.addEventListener('click', function () {
31683 hdr.closest('.ep-card').classList.toggle('open');
31684 });
31685 });
31686
31687 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
31688 btn.addEventListener('click', function () {
31689 var targetId = btn.dataset.target;
31690 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
31691 if (!pre) return;
31692 navigator.clipboard.writeText(pre.textContent).then(function () {
31693 btn.textContent = 'Copied!';
31694 btn.classList.add('copied');
31695 setTimeout(function () {
31696 btn.textContent = 'Copy';
31697 btn.classList.remove('copied');
31698 }, 2000);
31699 });
31700 });
31701 });
31702
31703 var storageKey = 'oxide-sloc-theme';
31704 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
31705 var themeBtn = document.getElementById('theme-toggle');
31706 if (themeBtn) {
31707 themeBtn.addEventListener('click', function () {
31708 var dark = document.body.classList.toggle('dark-theme');
31709 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
31710 });
31711 }
31712 (function() {
31713 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'}];
31714 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);});}
31715 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31716 var btn=document.getElementById('settings-btn');if(!btn)return;
31717 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31718 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>';
31719 document.body.appendChild(m);
31720 var g=document.getElementById('scheme-grid');
31721 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);});
31722 var cl=document.getElementById('settings-close');
31723 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);
31724 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');});
31725 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31726 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31727 })();
31728 (function randomizeWatermarks() {
31729 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31730 if (!wms.length) return;
31731 var placed = [];
31732 function tooClose(top, left) {
31733 for (var i = 0; i < placed.length; i++) {
31734 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31735 if (dt < 16 && dl < 12) return true;
31736 }
31737 return false;
31738 }
31739 function pick(leftBand) {
31740 for (var attempt = 0; attempt < 50; attempt++) {
31741 var top = Math.random() * 88 + 2;
31742 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31743 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31744 }
31745 var top = Math.random() * 88 + 2;
31746 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31747 placed.push([top, left]); return [top, left];
31748 }
31749 var half = Math.floor(wms.length / 2);
31750 wms.forEach(function (img, i) {
31751 var pos = pick(i < half);
31752 var size = Math.floor(Math.random() * 100 + 120);
31753 var rot = (Math.random() * 360).toFixed(1);
31754 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31755 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;
31756 });
31757 })();
31758 (function spawnCodeParticles() {
31759 var container = document.getElementById('code-particles');
31760 if (!container) return;
31761 var snippets = [
31762 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31763 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31764 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31765 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31766 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31767 ];
31768 var count = 38;
31769 for (var i = 0; i < count; i++) {
31770 (function(idx) {
31771 var el = document.createElement('span');
31772 el.className = 'code-particle';
31773 el.textContent = snippets[idx % snippets.length];
31774 var left = Math.random() * 94 + 2;
31775 var top = Math.random() * 88 + 6;
31776 var dur = (Math.random() * 10 + 9).toFixed(1);
31777 var delay = (Math.random() * 18).toFixed(1);
31778 var rot = (Math.random() * 26 - 13).toFixed(1);
31779 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31780 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31781 container.appendChild(el);
31782 })(i);
31783 }
31784 })();
31785 }());
31786 </script>
31787</body>
31788</html>
31789"##,
31790 ext = "html"
31791)]
31792struct ApiDocsTemplate {
31793 has_api_key: bool,
31794 csp_nonce: String,
31795 version: &'static str,
31796}
31797
31798#[cfg(test)]
31799mod form_config_tests {
31800 use super::*;
31801 use sloc_config::{
31802 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
31803 };
31804
31805 fn blank_form() -> AnalyzeForm {
31806 AnalyzeForm {
31807 path: ".".to_string(),
31808 git_repo: None,
31809 git_ref: None,
31810 mixed_line_policy: None,
31811 python_docstrings_as_comments: None,
31812 generated_file_detection: None,
31813 minified_file_detection: None,
31814 vendor_directory_detection: None,
31815 include_lockfiles: None,
31816 binary_file_behavior: None,
31817 output_dir: None,
31818 report_title: None,
31819 report_header_footer: None,
31820 include_globs: None,
31821 exclude_globs: None,
31822 submodule_breakdown: None,
31823 coverage_file: None,
31824 continuation_line_policy: None,
31825 blank_in_block_comment_policy: None,
31826 count_compiler_directives: None,
31827 style_col_threshold: None,
31828 style_analysis_enabled: None,
31829 style_score_threshold: None,
31830 style_lang_scope: None,
31831 cocomo_mode: None,
31832 complexity_alert: None,
31833 exclude_duplicates: None,
31834 activity_window: None,
31835 }
31836 }
31837
31838 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
31839 let mut cfg = sloc_config::AppConfig::default();
31840 apply_form_to_config(&mut cfg, form);
31841 cfg
31842 }
31843
31844 #[test]
31847 fn extract_long_commit_picks_super_repo_by_short_prefix() {
31848 let dir = tempfile::tempdir().unwrap();
31852 let path = dir.path().join("result.json");
31853 let body = r#"{
31854 "submodules": [
31855 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
31856 { "git_commit_long": null }
31857 ],
31858 "git_commit_short": "4c2cd9b",
31859 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
31860}"#;
31861 std::fs::write(&path, body).unwrap();
31862 assert_eq!(
31863 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
31864 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
31865 );
31866 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
31868 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
31869 }
31870
31871 #[test]
31872 fn activity_window_defaults_on_when_field_blank() {
31873 let cfg = apply(&blank_form());
31875 assert_eq!(cfg.analysis.activity_window_days, Some(90));
31876 }
31877
31878 #[test]
31879 fn activity_window_override_sets_days() {
31880 let mut form = blank_form();
31881 form.activity_window = Some("30".to_string());
31882 let cfg = apply(&form);
31883 assert_eq!(cfg.analysis.activity_window_days, Some(30));
31884 }
31885
31886 #[test]
31887 fn activity_window_zero_disables() {
31888 let mut form = blank_form();
31890 form.activity_window = Some("0".to_string());
31891 let cfg = apply(&form);
31892 assert_eq!(cfg.analysis.activity_window_days, Some(0));
31893 }
31894
31895 #[test]
31898 fn python_docstrings_false_when_unchecked() {
31899 let cfg = apply(&blank_form());
31901 assert!(
31902 !cfg.analysis.python_docstrings_as_comments,
31903 "absent python_docstrings_as_comments must map to false"
31904 );
31905 }
31906
31907 #[test]
31908 fn python_docstrings_true_when_checked() {
31909 let mut form = blank_form();
31911 form.python_docstrings_as_comments = Some("on".to_string());
31912 let cfg = apply(&form);
31913 assert!(cfg.analysis.python_docstrings_as_comments);
31914 }
31915
31916 #[test]
31917 fn python_docstrings_true_for_any_non_none_value() {
31918 let mut form = blank_form();
31920 form.python_docstrings_as_comments = Some("true".to_string());
31921 assert!(apply(&form).analysis.python_docstrings_as_comments);
31922 }
31923
31924 #[test]
31927 fn submodule_breakdown_false_when_unchecked() {
31928 let cfg = apply(&blank_form());
31929 assert!(
31930 !cfg.discovery.submodule_breakdown,
31931 "absent submodule_breakdown must map to false"
31932 );
31933 }
31934
31935 #[test]
31936 fn submodule_breakdown_true_when_value_enabled() {
31937 let mut form = blank_form();
31938 form.submodule_breakdown = Some("enabled".to_string());
31939 assert!(apply(&form).discovery.submodule_breakdown);
31940 }
31941
31942 #[test]
31943 fn submodule_breakdown_false_for_wrong_value() {
31944 let mut form = blank_form();
31946 form.submodule_breakdown = Some("on".to_string());
31947 assert!(
31948 !apply(&form).discovery.submodule_breakdown,
31949 "submodule_breakdown only becomes true for the exact value 'enabled'"
31950 );
31951 }
31952
31953 #[test]
31956 fn generated_detection_true_when_enabled() {
31957 let mut form = blank_form();
31958 form.generated_file_detection = Some("enabled".to_string());
31959 assert!(apply(&form).analysis.generated_file_detection);
31960 }
31961
31962 #[test]
31963 fn generated_detection_false_when_disabled() {
31964 let mut form = blank_form();
31965 form.generated_file_detection = Some("disabled".to_string());
31966 assert!(!apply(&form).analysis.generated_file_detection);
31967 }
31968
31969 #[test]
31970 fn generated_detection_true_when_absent() {
31971 assert!(
31973 apply(&blank_form()).analysis.generated_file_detection,
31974 "absent field must default to true (detection on)"
31975 );
31976 }
31977
31978 #[test]
31981 fn minified_detection_false_when_disabled() {
31982 let mut form = blank_form();
31983 form.minified_file_detection = Some("disabled".to_string());
31984 assert!(!apply(&form).analysis.minified_file_detection);
31985 }
31986
31987 #[test]
31988 fn minified_detection_true_when_enabled() {
31989 let mut form = blank_form();
31990 form.minified_file_detection = Some("enabled".to_string());
31991 assert!(apply(&form).analysis.minified_file_detection);
31992 }
31993
31994 #[test]
31995 fn minified_detection_true_when_absent() {
31996 assert!(apply(&blank_form()).analysis.minified_file_detection);
31997 }
31998
31999 #[test]
32002 fn vendor_detection_false_when_disabled() {
32003 let mut form = blank_form();
32004 form.vendor_directory_detection = Some("disabled".to_string());
32005 assert!(!apply(&form).analysis.vendor_directory_detection);
32006 }
32007
32008 #[test]
32009 fn vendor_detection_true_when_enabled() {
32010 let mut form = blank_form();
32011 form.vendor_directory_detection = Some("enabled".to_string());
32012 assert!(apply(&form).analysis.vendor_directory_detection);
32013 }
32014
32015 #[test]
32016 fn vendor_detection_true_when_absent() {
32017 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32018 }
32019
32020 #[test]
32023 fn lockfiles_false_when_absent() {
32024 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32026 }
32027
32028 #[test]
32029 fn lockfiles_false_when_disabled() {
32030 let mut form = blank_form();
32031 form.include_lockfiles = Some("disabled".to_string());
32032 assert!(!apply(&form).analysis.include_lockfiles);
32033 }
32034
32035 #[test]
32036 fn lockfiles_true_when_enabled() {
32037 let mut form = blank_form();
32038 form.include_lockfiles = Some("enabled".to_string());
32039 assert!(apply(&form).analysis.include_lockfiles);
32040 }
32041
32042 #[test]
32045 fn compiler_directives_true_when_absent() {
32046 assert!(
32047 apply(&blank_form()).analysis.count_compiler_directives,
32048 "absent count_compiler_directives must default to true"
32049 );
32050 }
32051
32052 #[test]
32053 fn compiler_directives_true_when_enabled() {
32054 let mut form = blank_form();
32055 form.count_compiler_directives = Some("enabled".to_string());
32056 assert!(apply(&form).analysis.count_compiler_directives);
32057 }
32058
32059 #[test]
32060 fn compiler_directives_false_when_disabled() {
32061 let mut form = blank_form();
32062 form.count_compiler_directives = Some("disabled".to_string());
32063 assert!(!apply(&form).analysis.count_compiler_directives);
32064 }
32065
32066 #[test]
32069 fn mixed_policy_unchanged_when_absent() {
32070 assert_eq!(
32072 apply(&blank_form()).analysis.mixed_line_policy,
32073 MixedLinePolicy::CodeOnly
32074 );
32075 }
32076
32077 #[test]
32078 fn mixed_policy_code_only() {
32079 let mut form = blank_form();
32080 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32081 assert_eq!(
32082 apply(&form).analysis.mixed_line_policy,
32083 MixedLinePolicy::CodeOnly
32084 );
32085 }
32086
32087 #[test]
32088 fn mixed_policy_code_and_comment() {
32089 let mut form = blank_form();
32090 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32091 assert_eq!(
32092 apply(&form).analysis.mixed_line_policy,
32093 MixedLinePolicy::CodeAndComment
32094 );
32095 }
32096
32097 #[test]
32098 fn mixed_policy_comment_only() {
32099 let mut form = blank_form();
32100 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32101 assert_eq!(
32102 apply(&form).analysis.mixed_line_policy,
32103 MixedLinePolicy::CommentOnly
32104 );
32105 }
32106
32107 #[test]
32108 fn mixed_policy_separate_mixed_category() {
32109 let mut form = blank_form();
32110 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32111 assert_eq!(
32112 apply(&form).analysis.mixed_line_policy,
32113 MixedLinePolicy::SeparateMixedCategory
32114 );
32115 }
32116
32117 #[test]
32120 fn binary_behavior_skip_when_absent() {
32121 assert_eq!(
32122 apply(&blank_form()).analysis.binary_file_behavior,
32123 BinaryFileBehavior::Skip
32124 );
32125 }
32126
32127 #[test]
32128 fn binary_behavior_skip() {
32129 let mut form = blank_form();
32130 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32131 assert_eq!(
32132 apply(&form).analysis.binary_file_behavior,
32133 BinaryFileBehavior::Skip
32134 );
32135 }
32136
32137 #[test]
32138 fn binary_behavior_fail() {
32139 let mut form = blank_form();
32140 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32141 assert_eq!(
32142 apply(&form).analysis.binary_file_behavior,
32143 BinaryFileBehavior::Fail
32144 );
32145 }
32146
32147 #[test]
32150 fn continuation_policy_each_physical_when_absent() {
32151 assert_eq!(
32152 apply(&blank_form()).analysis.continuation_line_policy,
32153 ContinuationLinePolicy::EachPhysicalLine
32154 );
32155 }
32156
32157 #[test]
32158 fn continuation_policy_collapse_to_logical() {
32159 let mut form = blank_form();
32160 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32161 assert_eq!(
32162 apply(&form).analysis.continuation_line_policy,
32163 ContinuationLinePolicy::CollapseToLogical
32164 );
32165 }
32166
32167 #[test]
32170 fn blank_in_block_comment_count_as_comment_when_absent() {
32171 assert_eq!(
32172 apply(&blank_form()).analysis.blank_in_block_comment_policy,
32173 BlankInBlockCommentPolicy::CountAsComment
32174 );
32175 }
32176
32177 #[test]
32178 fn blank_in_block_comment_count_as_blank() {
32179 let mut form = blank_form();
32180 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32181 assert_eq!(
32182 apply(&form).analysis.blank_in_block_comment_policy,
32183 BlankInBlockCommentPolicy::CountAsBlank
32184 );
32185 }
32186
32187 #[test]
32190 fn style_threshold_80() {
32191 let mut form = blank_form();
32192 form.style_col_threshold = Some("80".to_string());
32193 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32194 }
32195
32196 #[test]
32197 fn style_threshold_100() {
32198 let mut form = blank_form();
32199 form.style_col_threshold = Some("100".to_string());
32200 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32201 }
32202
32203 #[test]
32204 fn style_threshold_120() {
32205 let mut form = blank_form();
32206 form.style_col_threshold = Some("120".to_string());
32207 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32208 }
32209
32210 #[test]
32211 fn style_threshold_invalid_value_leaves_default() {
32212 let mut cfg = sloc_config::AppConfig::default();
32214 let mut form = blank_form();
32215 form.style_col_threshold = Some("42".to_string());
32216 apply_form_to_config(&mut cfg, &form);
32217 assert_eq!(
32218 cfg.analysis.style_col_threshold, 80,
32219 "invalid threshold must not change config"
32220 );
32221 }
32222
32223 #[test]
32224 fn style_threshold_non_numeric_leaves_default() {
32225 let mut cfg = sloc_config::AppConfig::default();
32226 let mut form = blank_form();
32227 form.style_col_threshold = Some("large".to_string());
32228 apply_form_to_config(&mut cfg, &form);
32229 assert_eq!(cfg.analysis.style_col_threshold, 80);
32230 }
32231
32232 #[test]
32233 fn style_threshold_zero_leaves_default() {
32234 let mut cfg = sloc_config::AppConfig::default();
32235 let mut form = blank_form();
32236 form.style_col_threshold = Some("0".to_string());
32237 apply_form_to_config(&mut cfg, &form);
32238 assert_eq!(cfg.analysis.style_col_threshold, 80);
32239 }
32240
32241 #[test]
32242 fn style_threshold_absent_leaves_default() {
32243 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32244 }
32245
32246 #[test]
32249 fn style_score_threshold_zero_when_absent() {
32250 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32251 }
32252
32253 #[test]
32254 fn style_score_threshold_set_to_valid_value() {
32255 let mut form = blank_form();
32256 form.style_score_threshold = Some("70".to_string());
32257 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32258 }
32259
32260 #[test]
32261 fn style_score_threshold_clamps_to_100_when_over() {
32262 let mut form = blank_form();
32264 form.style_score_threshold = Some("200".to_string());
32265 assert_eq!(
32266 apply(&form).analysis.style_score_threshold,
32267 100,
32268 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32269 );
32270 }
32271
32272 #[test]
32275 fn coverage_file_none_when_absent() {
32276 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32277 }
32278
32279 #[test]
32280 fn coverage_file_none_when_whitespace_only() {
32281 let mut form = blank_form();
32282 form.coverage_file = Some(" ".to_string());
32283 assert!(
32284 apply(&form).analysis.coverage_file.is_none(),
32285 "whitespace-only coverage_file must be treated as None"
32286 );
32287 }
32288
32289 #[test]
32290 fn coverage_file_set_when_non_empty() {
32291 let mut form = blank_form();
32292 form.coverage_file = Some("coverage/lcov.info".to_string());
32293 assert_eq!(
32294 apply(&form).analysis.coverage_file,
32295 Some(std::path::PathBuf::from("coverage/lcov.info"))
32296 );
32297 }
32298
32299 #[test]
32300 fn coverage_file_trims_whitespace() {
32301 let mut form = blank_form();
32302 form.coverage_file = Some(" coverage/lcov.info ".to_string());
32303 assert_eq!(
32304 apply(&form).analysis.coverage_file,
32305 Some(std::path::PathBuf::from("coverage/lcov.info"))
32306 );
32307 }
32308
32309 #[test]
32312 fn report_title_unchanged_when_absent() {
32313 let original = sloc_config::AppConfig::default().reporting.report_title;
32314 assert_eq!(apply(&blank_form()).reporting.report_title, original);
32315 }
32316
32317 #[test]
32318 fn report_title_unchanged_when_whitespace_only() {
32319 let original = sloc_config::AppConfig::default().reporting.report_title;
32320 let mut form = blank_form();
32321 form.report_title = Some(" ".to_string());
32322 assert_eq!(
32323 apply(&form).reporting.report_title,
32324 original,
32325 "whitespace-only title must not overwrite the default"
32326 );
32327 }
32328
32329 #[test]
32330 fn report_title_updated_and_trimmed() {
32331 let mut form = blank_form();
32332 form.report_title = Some(" My Project ".to_string());
32333 assert_eq!(apply(&form).reporting.report_title, "My Project");
32334 }
32335
32336 #[test]
32339 fn header_footer_none_when_absent() {
32340 assert!(apply(&blank_form())
32341 .reporting
32342 .report_header_footer
32343 .is_none());
32344 }
32345
32346 #[test]
32347 fn header_footer_none_when_whitespace_only() {
32348 let mut form = blank_form();
32349 form.report_header_footer = Some(" ".to_string());
32350 assert!(apply(&form).reporting.report_header_footer.is_none());
32351 }
32352
32353 #[test]
32354 fn header_footer_set_and_trimmed() {
32355 let mut form = blank_form();
32356 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
32357 assert_eq!(
32358 apply(&form).reporting.report_header_footer,
32359 Some("Confidential — Internal Use".to_string())
32360 );
32361 }
32362
32363 #[test]
32366 fn include_globs_empty_when_absent() {
32367 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32368 }
32369
32370 #[test]
32371 fn include_globs_newline_separated() {
32372 let mut form = blank_form();
32373 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32374 assert_eq!(
32375 apply(&form).discovery.include_globs,
32376 vec!["src/**/*.rs", "tests/**/*.rs"]
32377 );
32378 }
32379
32380 #[test]
32381 fn exclude_globs_comma_separated() {
32382 let mut form = blank_form();
32383 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32384 assert_eq!(
32385 apply(&form).discovery.exclude_globs,
32386 vec!["vendor/**", "node_modules/**"]
32387 );
32388 }
32389
32390 #[test]
32391 fn globs_mixed_separators() {
32392 let mut form = blank_form();
32393 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32394 assert_eq!(
32395 apply(&form).discovery.exclude_globs,
32396 vec!["a/**", "b/**", "c/**"]
32397 );
32398 }
32399
32400 #[test]
32403 fn split_patterns_none_is_empty() {
32404 assert!(split_patterns(None).is_empty());
32405 }
32406
32407 #[test]
32408 fn split_patterns_empty_string_is_empty() {
32409 assert!(split_patterns(Some("")).is_empty());
32410 }
32411
32412 #[test]
32413 fn split_patterns_whitespace_only_is_empty() {
32414 assert!(split_patterns(Some(" \n \n ")).is_empty());
32415 }
32416
32417 #[test]
32418 fn split_patterns_newlines() {
32419 assert_eq!(
32420 split_patterns(Some("a/**\nb/**\nc/**")),
32421 vec!["a/**", "b/**", "c/**"]
32422 );
32423 }
32424
32425 #[test]
32426 fn split_patterns_commas() {
32427 assert_eq!(
32428 split_patterns(Some("a/**,b/**,c/**")),
32429 vec!["a/**", "b/**", "c/**"]
32430 );
32431 }
32432
32433 #[test]
32434 fn split_patterns_mixed() {
32435 assert_eq!(
32436 split_patterns(Some("a/**\nb/**,c/**")),
32437 vec!["a/**", "b/**", "c/**"]
32438 );
32439 }
32440
32441 #[test]
32442 fn split_patterns_trims_whitespace() {
32443 assert_eq!(
32444 split_patterns(Some(" a/** \n b/** ")),
32445 vec!["a/**", "b/**"]
32446 );
32447 }
32448
32449 #[test]
32450 fn split_patterns_filters_empty_entries() {
32451 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32452 }
32453
32454 #[test]
32455 fn split_patterns_single_entry() {
32456 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32457 }
32458}
32459
32460#[cfg(test)]
32461mod utility_tests {
32462 use super::*;
32463 use std::net::IpAddr;
32464 use std::time::Duration;
32465
32466 #[test]
32469 fn sanitize_simple_name() {
32470 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32471 }
32472
32473 #[test]
32474 fn sanitize_uppercased_lowercased() {
32475 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32476 }
32477
32478 #[test]
32479 fn sanitize_path_extracts_filename() {
32480 assert_eq!(
32481 sanitize_project_label("/home/user/my-project"),
32482 "my-project"
32483 );
32484 }
32485
32486 #[test]
32487 fn sanitize_path_uses_last_component() {
32488 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32489 }
32490
32491 #[test]
32492 fn sanitize_spaces_become_hyphens() {
32493 assert_eq!(sanitize_project_label("my project"), "my-project");
32494 }
32495
32496 #[test]
32497 fn sanitize_non_ascii_become_hyphens() {
32498 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32499 }
32500
32501 #[test]
32502 fn sanitize_all_special_chars_gives_project() {
32503 assert_eq!(sanitize_project_label("!@#$%^"), "project");
32504 }
32505
32506 #[test]
32507 fn sanitize_empty_string_gives_project() {
32508 assert_eq!(sanitize_project_label(""), "project");
32509 }
32510
32511 #[test]
32512 fn sanitize_leading_trailing_hyphens_stripped() {
32513 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
32514 }
32515
32516 #[test]
32517 fn sanitize_alphanumeric_preserved() {
32518 assert_eq!(sanitize_project_label("repo123"), "repo123");
32519 }
32520
32521 #[test]
32522 fn sanitize_dots_become_hyphens() {
32523 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
32524 }
32525
32526 #[test]
32527 fn sanitize_mixed_slashes_uses_filename() {
32528 assert_eq!(sanitize_project_label("project-name"), "project-name");
32530 }
32531
32532 #[test]
32535 fn rate_limiter_allows_first_request() {
32536 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
32537 let ip: IpAddr = "127.0.0.1".parse().unwrap();
32538 assert!(rl.is_allowed(ip));
32539 }
32540
32541 #[test]
32542 fn rate_limiter_blocks_after_limit_reached() {
32543 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
32544 let ip: IpAddr = "10.0.0.1".parse().unwrap();
32545 assert!(rl.is_allowed(ip));
32546 assert!(rl.is_allowed(ip));
32547 assert!(rl.is_allowed(ip));
32548 assert!(!rl.is_allowed(ip), "4th request must be blocked");
32549 }
32550
32551 #[test]
32552 fn rate_limiter_allows_requests_up_to_limit() {
32553 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
32554 let ip: IpAddr = "10.0.0.2".parse().unwrap();
32555 for _ in 0..5 {
32556 assert!(rl.is_allowed(ip));
32557 }
32558 assert!(!rl.is_allowed(ip), "6th request must be blocked");
32559 }
32560
32561 #[test]
32562 fn rate_limiter_different_ips_are_independent() {
32563 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
32564 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
32565 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
32566 assert!(rl.is_allowed(ip1));
32567 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
32568 assert!(rl.is_allowed(ip2), "ip2 must be independent");
32569 }
32570
32571 #[test]
32572 fn rate_limiter_auth_failure_not_locked_below_threshold() {
32573 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32574 let ip: IpAddr = "10.0.0.3".parse().unwrap();
32575 rl.record_auth_failure(ip);
32576 rl.record_auth_failure(ip);
32577 assert!(
32578 !rl.is_auth_locked_out(ip),
32579 "not locked at 2 failures when threshold is 3"
32580 );
32581 }
32582
32583 #[test]
32584 fn rate_limiter_auth_failure_locked_at_threshold() {
32585 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32586 let ip: IpAddr = "10.0.0.4".parse().unwrap();
32587 rl.record_auth_failure(ip);
32588 rl.record_auth_failure(ip);
32589 rl.record_auth_failure(ip);
32590 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
32591 }
32592
32593 #[test]
32594 fn rate_limiter_auth_failure_different_ips_independent() {
32595 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
32596 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
32597 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
32598 rl.record_auth_failure(ip1);
32599 rl.record_auth_failure(ip1);
32600 assert!(rl.is_auth_locked_out(ip1));
32601 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
32602 }
32603
32604 #[test]
32605 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
32606 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
32607 let ip: IpAddr = "127.0.0.2".parse().unwrap();
32608 for _ in 0..100 {
32609 assert!(rl.is_allowed(ip));
32610 }
32611 }
32612
32613 #[test]
32616 fn strip_unc_plain_path_unchanged() {
32617 let p = PathBuf::from("C:\\Users\\user\\project");
32618 let result = strip_unc_prefix(p.clone());
32619 assert_eq!(result, p);
32620 }
32621
32622 #[test]
32623 fn strip_unc_with_drive_prefix_stripped() {
32624 let p = PathBuf::from(r"\\?\C:\Users\user\project");
32625 let result = strip_unc_prefix(p);
32626 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
32627 }
32628
32629 #[test]
32630 fn strip_unc_with_network_prefix_stripped() {
32631 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
32632 let result = strip_unc_prefix(p);
32633 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
32634 }
32635
32636 #[test]
32637 fn strip_unc_linux_path_unchanged() {
32638 let p = PathBuf::from("/home/user/project");
32639 let result = strip_unc_prefix(p.clone());
32640 assert_eq!(result, p);
32641 }
32642
32643 #[test]
32646 fn remote_to_commit_url_github_https() {
32647 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
32648 assert_eq!(
32649 url,
32650 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32651 );
32652 }
32653
32654 #[test]
32655 fn remote_to_commit_url_github_ssh() {
32656 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
32657 assert_eq!(
32658 url,
32659 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32660 );
32661 }
32662
32663 #[test]
32664 fn remote_to_commit_url_gitlab_uses_dash_commit() {
32665 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
32666 assert_eq!(
32667 url,
32668 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
32669 );
32670 }
32671
32672 #[test]
32673 fn remote_to_commit_url_bitbucket_uses_commits() {
32674 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
32675 assert_eq!(
32676 url,
32677 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
32678 );
32679 }
32680
32681 #[test]
32682 fn remote_to_commit_url_unknown_scheme_returns_none() {
32683 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
32684 assert!(url.is_none());
32685 }
32686
32687 #[test]
32688 fn remote_to_commit_url_ssh_gitlab() {
32689 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
32690 assert!(url.is_some());
32691 let u = url.unwrap();
32692 assert!(
32693 u.contains("/-/commit/sha123"),
32694 "gitlab ssh must use /-/commit/"
32695 );
32696 }
32697
32698 #[test]
32701 fn git_clone_dest_github_url_produces_safe_name() {
32702 let dir = PathBuf::from("/tmp/clones");
32703 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32704 let name = dest.file_name().unwrap().to_string_lossy();
32705 assert!(!name.is_empty());
32706 assert!(
32707 name.chars()
32708 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
32709 "clone dest must only contain safe chars, got: {name}"
32710 );
32711 }
32712
32713 #[test]
32714 fn git_clone_dest_is_inside_clones_dir() {
32715 let dir = PathBuf::from("/tmp/clones");
32716 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32717 assert!(
32718 dest.starts_with(&dir),
32719 "clone dest must be inside clones_dir"
32720 );
32721 }
32722
32723 #[test]
32724 fn git_clone_dest_truncates_to_80_chars_max() {
32725 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
32726 let dir = PathBuf::from("/tmp/clones");
32727 let dest = git_clone_dest(&long_url, &dir);
32728 let name = dest.file_name().unwrap().to_string_lossy();
32729 assert!(
32730 name.len() <= 80,
32731 "clone dest name must be at most 80 chars, got {} chars: {name}",
32732 name.len()
32733 );
32734 }
32735
32736 #[test]
32737 fn git_clone_dest_special_chars_replaced_with_underscore() {
32738 let dir = PathBuf::from("/tmp/clones");
32739 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
32740 let name = dest.file_name().unwrap().to_string_lossy();
32741 assert!(
32742 !name.contains('@') && !name.contains(':') && !name.contains('/'),
32743 "special chars must be replaced in clone dest, got: {name}"
32744 );
32745 }
32746
32747 #[test]
32748 fn git_clone_dest_different_urls_differ() {
32749 let dir = PathBuf::from("/tmp/clones");
32750 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
32751 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
32752 assert_ne!(
32753 a, b,
32754 "different repos must produce different clone dest names"
32755 );
32756 }
32757
32758 #[test]
32759 fn git_clone_dest_same_url_same_result() {
32760 let dir = PathBuf::from("/tmp/clones");
32761 let url = "https://github.com/owner/repo.git";
32762 assert_eq!(
32763 git_clone_dest(url, &dir),
32764 git_clone_dest(url, &dir),
32765 "same URL must always give same clone dest"
32766 );
32767 }
32768
32769 #[test]
32772 fn fmt_delta_positive_has_plus_prefix() {
32773 assert_eq!(fmt_delta(5), "+5");
32774 }
32775
32776 #[test]
32777 fn fmt_delta_negative_no_plus_prefix() {
32778 assert_eq!(fmt_delta(-3), "-3");
32779 }
32780
32781 #[test]
32782 fn fmt_delta_zero() {
32783 assert_eq!(fmt_delta(0), "0");
32784 }
32785
32786 #[test]
32789 fn delta_class_positive_is_pos() {
32790 assert_eq!(delta_class(1), "pos");
32791 }
32792
32793 #[test]
32794 fn delta_class_negative_is_neg() {
32795 assert_eq!(delta_class(-1), "neg");
32796 }
32797
32798 #[test]
32799 fn delta_class_zero_is_zero_class() {
32800 assert_eq!(delta_class(0), "zero");
32801 }
32802
32803 #[test]
32806 fn fmt_pct_zero_baseline_returns_em_dash() {
32807 assert_eq!(fmt_pct(100, 0), "\u{2014}");
32808 }
32809
32810 #[test]
32811 fn fmt_pct_positive_delta_has_plus_sign() {
32812 let result = fmt_pct(10, 100);
32813 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
32814 }
32815
32816 #[test]
32817 fn fmt_pct_negative_delta_no_plus_sign() {
32818 let result = fmt_pct(-10, 100);
32819 assert!(!result.starts_with('+'), "unexpected + in: {result}");
32820 assert!(result.contains('%'));
32821 }
32822
32823 #[test]
32824 fn fmt_pct_near_zero_returns_pm_zero() {
32825 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
32826 }
32827
32828 #[test]
32831 fn summary_delta_no_prev_returns_dash_na() {
32832 let (display, class) = summary_delta(10, None);
32833 assert_eq!(display, "\u{2014}");
32834 assert_eq!(class, "na");
32835 }
32836
32837 #[test]
32838 fn summary_delta_increase_is_positive() {
32839 let (display, class) = summary_delta(15, Some(10));
32840 assert_eq!(display, "+5");
32841 assert_eq!(class, "pos");
32842 }
32843
32844 #[test]
32845 fn summary_delta_decrease_is_negative() {
32846 let (display, class) = summary_delta(5, Some(10));
32847 assert_eq!(display, "-5");
32848 assert_eq!(class, "neg");
32849 }
32850
32851 #[test]
32854 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
32855 use chrono::Datelike;
32856 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
32857 assert_eq!(d.year(), 2024);
32858 assert_eq!(d.month(), 1);
32859 assert_eq!(d.weekday(), chrono::Weekday::Mon);
32860 assert!(d.day() <= 7);
32861 }
32862
32863 #[test]
32864 fn nth_weekday_second_sunday_march_2024_is_10th() {
32865 use chrono::Datelike;
32866 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
32867 assert_eq!(d.weekday(), chrono::Weekday::Sun);
32868 assert_eq!(d.month(), 3);
32869 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
32870 }
32871
32872 #[test]
32875 fn is_pacific_dst_july_is_true() {
32876 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32877 assert!(is_pacific_dst(dt), "July must be PDT");
32878 }
32879
32880 #[test]
32881 fn is_pacific_dst_january_is_false() {
32882 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32883 assert!(!is_pacific_dst(dt), "January must be PST");
32884 }
32885
32886 #[test]
32887 fn fmt_la_time_summer_shows_pdt() {
32888 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32889 let result = fmt_la_time(dt);
32890 assert!(
32891 result.ends_with("PDT"),
32892 "summer must use PDT, got: {result}"
32893 );
32894 }
32895
32896 #[test]
32897 fn fmt_la_time_winter_shows_pst() {
32898 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32899 let result = fmt_la_time(dt);
32900 assert!(
32901 result.ends_with("PST"),
32902 "winter must use PST, got: {result}"
32903 );
32904 }
32905
32906 #[test]
32907 fn fmt_la_time_meta_summer_shows_pdt() {
32908 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
32909 let result = fmt_la_time_meta(dt);
32910 assert!(
32911 result.ends_with("PDT"),
32912 "meta summer must use PDT, got: {result}"
32913 );
32914 }
32915
32916 #[test]
32917 fn fmt_la_time_meta_winter_shows_pst() {
32918 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
32919 let result = fmt_la_time_meta(dt);
32920 assert!(
32921 result.ends_with("PST"),
32922 "meta winter must use PST, got: {result}"
32923 );
32924 }
32925
32926 #[test]
32929 fn fmt_git_date_valid_iso_returns_some() {
32930 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
32931 }
32932
32933 #[test]
32934 fn fmt_git_date_invalid_returns_none() {
32935 assert!(fmt_git_date("not-a-date").is_none());
32936 }
32937
32938 #[test]
32941 fn format_number_zero() {
32942 assert_eq!(format_number(0), "0");
32943 }
32944
32945 #[test]
32946 fn format_number_three_digits_no_comma() {
32947 assert_eq!(format_number(999), "999");
32948 }
32949
32950 #[test]
32951 fn format_number_four_digits_has_comma() {
32952 assert_eq!(format_number(1000), "1,000");
32953 }
32954
32955 #[test]
32956 fn format_number_seven_digits_two_commas() {
32957 assert_eq!(format_number(1_234_567), "1,234,567");
32958 }
32959
32960 #[test]
32961 fn format_number_one_million() {
32962 assert_eq!(format_number(1_000_000), "1,000,000");
32963 }
32964
32965 #[test]
32968 fn badge_text_px_empty_is_zero() {
32969 assert_eq!(badge_text_px(""), 0);
32970 }
32971
32972 #[test]
32973 fn badge_text_px_narrow_chars_smaller_than_normal() {
32974 assert!(
32975 badge_text_px("if") < badge_text_px("ab"),
32976 "'if' must be narrower than 'ab'"
32977 );
32978 }
32979
32980 #[test]
32981 fn badge_text_px_m_is_wider_than_a() {
32982 assert!(
32983 badge_text_px("m") > badge_text_px("a"),
32984 "'m' must be wider than 'a'"
32985 );
32986 }
32987
32988 #[test]
32989 fn render_badge_svg_contains_label_and_value() {
32990 let svg = render_badge_svg("coverage", "95%", "#4c1");
32991 assert!(svg.contains("coverage") && svg.contains("95%"));
32992 }
32993
32994 #[test]
32995 fn render_badge_svg_contains_color() {
32996 let svg = render_badge_svg("sloc", "12K", "#e05d44");
32997 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
32998 }
32999
33000 #[test]
33001 fn render_badge_svg_escapes_ampersand_in_label() {
33002 let svg = render_badge_svg("test&label", "ok", "#4c1");
33003 assert!(svg.contains("&") && !svg.contains("test&label"));
33004 }
33005
33006 #[test]
33009 fn build_pdf_filename_slugifies_title() {
33010 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33011 assert!(
33012 name.starts_with("my_project_report_")
33013 && std::path::Path::new(&name)
33014 .extension()
33015 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33016 );
33017 }
33018
33019 #[test]
33020 fn build_pdf_filename_uses_last_run_id_segment() {
33021 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33022 assert!(name.contains("ABCD"), "must use last segment of run_id");
33023 }
33024
33025 #[test]
33026 fn build_pdf_filename_empty_title_uses_report_prefix() {
33027 let name = build_pdf_filename("", "abc-def-9999");
33028 assert!(
33029 name.starts_with("report_")
33030 && std::path::Path::new(&name)
33031 .extension()
33032 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33033 );
33034 }
33035
33036 #[test]
33039 fn swap_chart_js_replaces_inline_block() {
33040 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33041 let result = swap_inline_chart_js_for_static(html.to_string());
33042 assert!(result.contains(r#"src="/static/chart-report.js""#));
33043 assert!(!result.contains("inline source"));
33044 }
33045
33046 #[test]
33047 fn swap_chart_js_no_head_returns_unchanged() {
33048 let html = "<body>no head here</body>";
33049 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33050 }
33051
33052 #[test]
33053 fn swap_chart_js_no_script_in_head_unchanged() {
33054 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33055 let result = swap_inline_chart_js_for_static(html.to_string());
33056 assert!(!result.contains("chart-report.js"));
33057 }
33058
33059 #[test]
33062 fn patch_html_nonce_replaces_old_nonce() {
33063 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33064 let result = patch_html_nonce(html, "new-nonce-456");
33065 assert!(result.contains(r#"nonce="new-nonce-456""#));
33066 assert!(!result.contains("old-nonce-123"));
33067 }
33068
33069 #[test]
33070 fn patch_html_nonce_injects_into_bare_style() {
33071 let html = "<style>body{color:red;}</style>";
33072 let result = patch_html_nonce(html, "fresh-nonce");
33073 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33074 }
33075
33076 #[test]
33077 fn patch_html_nonce_injects_into_bare_script() {
33078 let html = "<script>console.log(1);</script>";
33079 let result = patch_html_nonce(html, "abc");
33080 assert!(result.contains(r#"<script nonce="abc">"#));
33081 }
33082
33083 #[test]
33086 fn is_html_report_file_result_html_matches() {
33087 let dir = tempfile::tempdir().unwrap();
33088 let path = dir.path().join("result_20240101.html");
33089 std::fs::write(&path, b"<html></html>").unwrap();
33090 assert!(is_html_report_file(&path));
33091 }
33092
33093 #[test]
33094 fn is_html_report_file_report_html_matches() {
33095 let dir = tempfile::tempdir().unwrap();
33096 let path = dir.path().join("report_abc.html");
33097 std::fs::write(&path, b"<html></html>").unwrap();
33098 assert!(is_html_report_file(&path));
33099 }
33100
33101 #[test]
33102 fn is_html_report_file_index_html_does_not_match() {
33103 let dir = tempfile::tempdir().unwrap();
33104 let path = dir.path().join("index.html");
33105 std::fs::write(&path, b"<html></html>").unwrap();
33106 assert!(!is_html_report_file(&path));
33107 }
33108
33109 #[test]
33110 fn is_html_report_file_nonexistent_returns_false() {
33111 assert!(!is_html_report_file(Path::new(
33112 "/nonexistent/result_xyz.html"
33113 )));
33114 }
33115
33116 #[test]
33117 fn find_html_report_in_dir_finds_result_html() {
33118 let dir = tempfile::tempdir().unwrap();
33119 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33120 assert!(find_html_report_in_dir(dir.path()).is_some());
33121 }
33122
33123 #[test]
33124 fn find_html_report_in_dir_empty_returns_none() {
33125 let dir = tempfile::tempdir().unwrap();
33126 assert!(find_html_report_in_dir(dir.path()).is_none());
33127 }
33128
33129 #[test]
33130 fn find_html_report_in_tree_finds_in_subdir() {
33131 let dir = tempfile::tempdir().unwrap();
33132 let subdir = dir.path().join("run-001");
33133 std::fs::create_dir_all(&subdir).unwrap();
33134 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33135 assert!(find_html_report_in_tree(dir.path()).is_some());
33136 }
33137
33138 #[test]
33141 fn derive_project_label_with_git_repo_and_ref() {
33142 let label = derive_project_label(
33143 Some("https://github.com/owner/my-repo.git"),
33144 Some("main"),
33145 "/fallback/path",
33146 );
33147 assert!(!label.is_empty(), "label must not be empty");
33148 assert!(
33149 label.contains("my") || label.contains("repo"),
33150 "got: {label}"
33151 );
33152 }
33153
33154 #[test]
33155 fn derive_project_label_fallback_to_path() {
33156 let label = derive_project_label(None, None, "/path/to/myproject");
33157 assert_eq!(label, "myproject");
33158 }
33159
33160 #[test]
33161 fn derive_project_label_empty_git_fields_use_path() {
33162 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33163 assert_eq!(label, "cool-app");
33164 }
33165
33166 #[test]
33169 fn derive_file_stem_with_commit_appends_sha() {
33170 assert_eq!(
33171 derive_file_stem("myproject", Some("a1b2c3")),
33172 "myproject_a1b2c3"
33173 );
33174 }
33175
33176 #[test]
33177 fn derive_file_stem_without_commit_returns_label() {
33178 assert_eq!(derive_file_stem("myproject", None), "myproject");
33179 }
33180
33181 #[test]
33182 fn derive_file_stem_empty_commit_returns_label() {
33183 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33184 }
33185
33186 #[test]
33189 fn split_patterns_none_is_empty() {
33190 assert!(split_patterns(None).is_empty());
33191 }
33192
33193 #[test]
33194 fn split_patterns_empty_string_is_empty() {
33195 assert!(split_patterns(Some("")).is_empty());
33196 }
33197
33198 #[test]
33199 fn split_patterns_comma_separated() {
33200 assert_eq!(
33201 split_patterns(Some("foo,bar,baz")),
33202 vec!["foo", "bar", "baz"]
33203 );
33204 }
33205
33206 #[test]
33207 fn split_patterns_newline_separated() {
33208 assert_eq!(
33209 split_patterns(Some("foo\nbar\nbaz")),
33210 vec!["foo", "bar", "baz"]
33211 );
33212 }
33213
33214 #[test]
33215 fn split_patterns_trims_whitespace() {
33216 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
33217 }
33218
33219 #[test]
33222 fn make_git_label_empty_repo_empty_result() {
33223 assert_eq!(make_git_label("", "main"), "");
33224 }
33225
33226 #[test]
33227 fn make_git_label_empty_ref_empty_result() {
33228 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33229 }
33230
33231 #[test]
33232 fn make_git_label_basic_format() {
33233 assert_eq!(
33234 make_git_label("https://github.com/owner/my-repo.git", "main"),
33235 "my-repo_at_main_sloc"
33236 );
33237 }
33238
33239 #[test]
33240 fn make_git_label_slash_in_ref_replaced() {
33241 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33242 assert!(
33243 !label.contains('/'),
33244 "slash in ref must be replaced: {label}"
33245 );
33246 }
33247
33248 #[test]
33251 fn format_dir_size_bytes() {
33252 assert_eq!(format_dir_size(500), "500 B");
33253 }
33254
33255 #[test]
33256 fn format_dir_size_kilobytes() {
33257 assert_eq!(format_dir_size(2048), "2 KB");
33258 }
33259
33260 #[test]
33261 fn format_dir_size_megabytes() {
33262 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33263 }
33264
33265 #[test]
33266 fn format_dir_size_gigabytes() {
33267 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33268 }
33269
33270 #[test]
33271 fn format_dir_size_zero() {
33272 assert_eq!(format_dir_size(0), "0 B");
33273 }
33274
33275 #[test]
33278 fn civil_from_days_epoch() {
33279 assert_eq!(civil_from_days(0), (1970, 1, 1));
33280 }
33281
33282 #[test]
33283 fn civil_from_days_one_year_later() {
33284 assert_eq!(civil_from_days(365), (1971, 1, 1));
33285 }
33286
33287 #[test]
33288 fn civil_from_days_31_days_is_feb_1_1970() {
33289 assert_eq!(civil_from_days(31), (1970, 2, 1));
33290 }
33291
33292 #[test]
33295 fn format_system_time_unix_epoch_formats_correctly() {
33296 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33297 }
33298
33299 #[test]
33300 fn format_system_time_31_days_after_epoch() {
33301 let t = UNIX_EPOCH + Duration::from_hours(744);
33302 assert_eq!(format_system_time(t), "1970-02-01 00:00");
33303 }
33304
33305 #[test]
33306 fn format_system_time_before_epoch_returns_dash() {
33307 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33308 assert_eq!(format_system_time(before), "-");
33309 }
33310 }
33311
33312 #[test]
33315 fn detect_language_name_dot_c() {
33316 assert_eq!(detect_language_name("main.c"), Some("C"));
33317 }
33318
33319 #[test]
33320 fn detect_language_name_dot_h() {
33321 assert_eq!(detect_language_name("defs.h"), Some("C"));
33322 }
33323
33324 #[test]
33325 fn detect_language_name_dot_cpp() {
33326 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33327 }
33328
33329 #[test]
33330 fn detect_language_name_dot_py() {
33331 assert_eq!(detect_language_name("script.py"), Some("Python"));
33332 }
33333
33334 #[test]
33335 fn detect_language_name_dot_ps1() {
33336 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33337 }
33338
33339 #[test]
33340 fn detect_language_name_dot_cs() {
33341 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33342 }
33343
33344 #[test]
33345 fn detect_language_name_dot_sh() {
33346 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33347 }
33348
33349 #[test]
33350 fn detect_language_name_unknown_txt() {
33351 assert_eq!(detect_language_name("notes.txt"), None);
33352 }
33353
33354 #[test]
33357 fn language_icon_file_c() {
33358 assert_eq!(language_icon_file("C"), Some("c.png"));
33359 }
33360
33361 #[test]
33362 fn language_icon_file_python() {
33363 assert_eq!(language_icon_file("Python"), Some("python.png"));
33364 }
33365
33366 #[test]
33367 fn language_icon_file_dockerfile() {
33368 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33369 }
33370
33371 #[test]
33372 fn language_icon_file_rust_is_none() {
33373 assert!(language_icon_file("Rust").is_none());
33374 }
33375
33376 #[test]
33377 fn language_icon_file_unknown_is_none() {
33378 assert!(language_icon_file("Fortran").is_none());
33379 }
33380
33381 #[test]
33384 fn language_inline_svg_rust_is_svg() {
33385 let svg = language_inline_svg("Rust").unwrap();
33386 assert!(svg.starts_with("<svg"));
33387 }
33388
33389 #[test]
33390 fn language_inline_svg_typescript_is_some() {
33391 assert!(language_inline_svg("TypeScript").is_some());
33392 }
33393
33394 #[test]
33395 fn language_inline_svg_unknown_is_none() {
33396 assert!(language_inline_svg("Fortran").is_none());
33397 }
33398
33399 #[test]
33402 fn classify_preview_file_c_supported() {
33403 assert!(matches!(
33404 classify_preview_file("main.c"),
33405 PreviewKind::Supported
33406 ));
33407 }
33408
33409 #[test]
33410 fn classify_preview_file_python_supported() {
33411 assert!(matches!(
33412 classify_preview_file("script.py"),
33413 PreviewKind::Supported
33414 ));
33415 }
33416
33417 #[test]
33418 fn classify_preview_file_png_skipped() {
33419 assert!(matches!(
33420 classify_preview_file("image.png"),
33421 PreviewKind::Skipped
33422 ));
33423 }
33424
33425 #[test]
33426 fn classify_preview_file_zip_skipped() {
33427 assert!(matches!(
33428 classify_preview_file("archive.zip"),
33429 PreviewKind::Skipped
33430 ));
33431 }
33432
33433 #[test]
33434 fn classify_preview_file_min_js_skipped() {
33435 assert!(matches!(
33436 classify_preview_file("bundle.min.js"),
33437 PreviewKind::Skipped
33438 ));
33439 }
33440
33441 #[test]
33442 fn classify_preview_file_rs_unsupported() {
33443 assert!(matches!(
33444 classify_preview_file("main.rs"),
33445 PreviewKind::Unsupported
33446 ));
33447 }
33448
33449 #[test]
33452 fn preview_relative_path_strips_root() {
33453 let root = PathBuf::from("/project");
33454 let path = PathBuf::from("/project/src/main.c");
33455 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33456 }
33457
33458 #[test]
33459 fn preview_relative_path_unrooted_includes_filename() {
33460 let root = PathBuf::from("/other");
33461 let path = PathBuf::from("/project/src/main.c");
33462 let result = preview_relative_path(&root, &path);
33463 assert!(result.contains("main.c"));
33464 }
33465
33466 #[test]
33467 fn preview_relative_path_uses_forward_slashes() {
33468 let root = PathBuf::from("/project");
33469 let path = PathBuf::from("/project/a/b/c.py");
33470 assert!(!preview_relative_path(&root, &path).contains('\\'));
33471 }
33472
33473 #[test]
33476 fn wildcard_match_exact_equal() {
33477 assert!(wildcard_match("foo", "foo"));
33478 }
33479
33480 #[test]
33481 fn wildcard_match_exact_mismatch() {
33482 assert!(!wildcard_match("foo", "bar"));
33483 }
33484
33485 #[test]
33486 fn wildcard_match_star_suffix() {
33487 assert!(wildcard_match("*.rs", "main.rs"));
33488 }
33489
33490 #[test]
33491 fn wildcard_match_star_middle_requires_suffix() {
33492 assert!(!wildcard_match("a*b", "ac"));
33493 }
33494
33495 #[test]
33496 fn wildcard_match_question_mark_single_char() {
33497 assert!(wildcard_match("f?o", "foo"));
33498 }
33499
33500 #[test]
33501 fn wildcard_match_double_star_nested() {
33502 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33503 }
33504
33505 #[test]
33506 fn wildcard_match_star_directory_entry() {
33507 assert!(wildcard_match("vendor/*", "vendor/crate"));
33508 }
33509
33510 #[test]
33511 fn wildcard_match_no_cross_prefix() {
33512 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
33513 }
33514
33515 #[test]
33518 fn should_skip_empty_relative_is_false() {
33519 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
33520 }
33521
33522 #[test]
33523 fn should_skip_matching_pattern() {
33524 assert!(should_skip_preview_directory(
33525 "vendor",
33526 &["vendor".to_string()]
33527 ));
33528 }
33529
33530 #[test]
33531 fn should_skip_non_matching() {
33532 assert!(!should_skip_preview_directory(
33533 "src",
33534 &["vendor".to_string()]
33535 ));
33536 }
33537
33538 #[test]
33539 fn should_skip_wildcard_prefix() {
33540 assert!(should_skip_preview_directory(
33541 "target/debug",
33542 &["target*".to_string()]
33543 ));
33544 }
33545
33546 #[test]
33549 fn should_include_empty_relative_always_true() {
33550 assert!(should_include_preview_file("", &[], &[]));
33551 }
33552
33553 #[test]
33554 fn should_include_no_patterns_includes_all() {
33555 assert!(should_include_preview_file("src/main.c", &[], &[]));
33556 }
33557
33558 #[test]
33559 fn should_include_excluded_by_pattern() {
33560 assert!(!should_include_preview_file(
33561 "vendor/lib.c",
33562 &[],
33563 &["vendor/*".to_string()]
33564 ));
33565 }
33566
33567 #[test]
33568 fn should_include_include_pattern_filters() {
33569 assert!(!should_include_preview_file(
33570 "tests/test_foo.c",
33571 &["src/*".to_string()],
33572 &[]
33573 ));
33574 }
33575
33576 #[test]
33579 fn escape_html_ampersand() {
33580 assert_eq!(escape_html("a&b"), "a&b");
33581 }
33582
33583 #[test]
33584 fn escape_html_angle_brackets() {
33585 assert_eq!(escape_html("<br>"), "<br>");
33586 }
33587
33588 #[test]
33589 fn escape_html_double_quote() {
33590 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
33591 }
33592
33593 #[test]
33594 fn escape_html_single_quote() {
33595 assert_eq!(escape_html("it's"), "it's");
33596 }
33597
33598 #[test]
33599 fn escape_html_plain_text_unchanged() {
33600 assert_eq!(escape_html("hello world"), "hello world");
33601 }
33602
33603 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
33606 sloc_core::ScanComparison {
33607 summary: sloc_core::SummaryDelta {
33608 baseline_run_id: "base".to_string(),
33609 current_run_id: "curr".to_string(),
33610 baseline_timestamp: chrono::Utc::now(),
33611 current_timestamp: chrono::Utc::now(),
33612 baseline_files: 4,
33613 current_files: 4,
33614 files_analyzed_delta: 0,
33615 baseline_code: 330,
33616 current_code: 400,
33617 code_lines_delta: 70,
33618 baseline_comments: 0,
33619 current_comments: 0,
33620 comment_lines_delta: 0,
33621 blank_lines_delta: 0,
33622 total_lines_delta: 70,
33623 coverage_lines_hit_delta: None,
33624 coverage_line_pct_delta: None,
33625 baseline_coverage_line_pct: None,
33626 current_coverage_line_pct: None,
33627 },
33628 file_deltas: vec![
33629 sloc_core::FileDelta {
33630 relative_path: "added.rs".to_string(),
33631 language: Some("Rust".to_string()),
33632 status: FileChangeStatus::Added,
33633 baseline_code: 0,
33634 current_code: 100,
33635 code_delta: 100,
33636 baseline_comment: 0,
33637 current_comment: 0,
33638 comment_delta: 0,
33639 baseline_blank: 0,
33640 current_blank: 0,
33641 blank_delta: 0,
33642 total_delta: 100,
33643 },
33644 sloc_core::FileDelta {
33645 relative_path: "removed.rs".to_string(),
33646 language: Some("Rust".to_string()),
33647 status: FileChangeStatus::Removed,
33648 baseline_code: 50,
33649 current_code: 0,
33650 code_delta: -50,
33651 baseline_comment: 0,
33652 current_comment: 0,
33653 comment_delta: 0,
33654 baseline_blank: 0,
33655 current_blank: 0,
33656 blank_delta: 0,
33657 total_delta: -50,
33658 },
33659 sloc_core::FileDelta {
33660 relative_path: "modified.rs".to_string(),
33661 language: Some("Rust".to_string()),
33662 status: FileChangeStatus::Modified,
33663 baseline_code: 80,
33664 current_code: 100,
33665 code_delta: 20,
33666 baseline_comment: 0,
33667 current_comment: 0,
33668 comment_delta: 0,
33669 baseline_blank: 0,
33670 current_blank: 0,
33671 blank_delta: 0,
33672 total_delta: 20,
33673 },
33674 sloc_core::FileDelta {
33675 relative_path: "unchanged.rs".to_string(),
33676 language: Some("Rust".to_string()),
33677 status: FileChangeStatus::Unchanged,
33678 baseline_code: 200,
33679 current_code: 200,
33680 code_delta: 0,
33681 baseline_comment: 0,
33682 current_comment: 0,
33683 comment_delta: 0,
33684 baseline_blank: 0,
33685 current_blank: 0,
33686 blank_delta: 0,
33687 total_delta: 0,
33688 },
33689 ],
33690 files_added: 1,
33691 files_removed: 1,
33692 files_modified: 1,
33693 files_unchanged: 1,
33694 }
33695 }
33696
33697 #[test]
33698 fn sum_added_counts_added_and_positive_modified() {
33699 let cmp = make_mixed_scan_comparison();
33700 assert_eq!(sum_added_code_lines(&cmp), 120);
33701 }
33702
33703 #[test]
33704 fn sum_removed_counts_removed_baseline() {
33705 let cmp = make_mixed_scan_comparison();
33706 assert_eq!(sum_removed_code_lines(&cmp), 50);
33707 }
33708
33709 #[test]
33710 fn sum_unmodified_counts_unchanged_files() {
33711 let cmp = make_mixed_scan_comparison();
33712 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
33713 }
33714
33715 #[test]
33718 fn detect_coverage_tool_rust_project() {
33719 let dir = tempfile::tempdir().unwrap();
33720 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
33721 let (tool, cmd) = detect_coverage_tool(dir.path());
33722 assert_eq!(tool, Some("cargo-llvm-cov"));
33723 assert!(cmd.is_some());
33724 }
33725
33726 #[test]
33727 fn detect_coverage_tool_java_gradle() {
33728 let dir = tempfile::tempdir().unwrap();
33729 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
33730 let (tool, _) = detect_coverage_tool(dir.path());
33731 assert_eq!(tool, Some("jacoco"));
33732 }
33733
33734 #[test]
33735 fn detect_coverage_tool_python_pyproject() {
33736 let dir = tempfile::tempdir().unwrap();
33737 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
33738 let (tool, _) = detect_coverage_tool(dir.path());
33739 assert_eq!(tool, Some("pytest-cov"));
33740 }
33741
33742 #[test]
33743 fn detect_coverage_tool_unknown_project() {
33744 let dir = tempfile::tempdir().unwrap();
33745 let (tool, cmd) = detect_coverage_tool(dir.path());
33746 assert!(tool.is_none() && cmd.is_none());
33747 }
33748
33749 #[test]
33752 fn sanitize_path_str_unc_drive_stripped() {
33753 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
33754 }
33755
33756 #[test]
33757 fn sanitize_path_str_unc_network_stripped() {
33758 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
33759 }
33760
33761 #[test]
33762 fn sanitize_path_str_plain_path_unchanged() {
33763 assert_eq!(
33764 sanitize_path_str("/home/user/project"),
33765 "/home/user/project"
33766 );
33767 }
33768
33769 #[test]
33770 fn display_path_plain_linux_unchanged() {
33771 assert_eq!(
33772 display_path(Path::new("/home/user/project")),
33773 "/home/user/project"
33774 );
33775 }
33776
33777 #[test]
33778 fn display_path_unc_drive_stripped() {
33779 let result = display_path(Path::new(r"\\?\C:\Users\user"));
33780 assert_eq!(result, r"C:\Users\user");
33781 }
33782
33783 #[test]
33784 fn display_path_unc_network_stripped() {
33785 let result = display_path(Path::new(r"\\?\UNC\server\share"));
33786 assert_eq!(result, r"\\server\share");
33787 }
33788}
33789
33790#[cfg(test)]
33791mod coverage_boost_unit_tests {
33792 use super::*;
33793 use std::path::{Path, PathBuf};
33794
33795 #[tokio::test]
33799 async fn runtime_security_config_scenarios() {
33800 std::env::remove_var("SLOC_API_KEYS");
33801 std::env::remove_var("SLOC_API_KEY");
33802 std::env::remove_var("SLOC_TLS_CERT");
33803 std::env::remove_var("SLOC_TLS_KEY");
33804 std::env::remove_var("SLOC_TRUST_PROXY");
33805 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33806 let cfg = load_runtime_security_config(false);
33807 assert!(cfg.api_keys.is_empty());
33808 assert!(!cfg.tls_enabled);
33809 assert!(!cfg.trust_proxy);
33810
33811 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
33812 std::env::set_var("SLOC_TRUST_PROXY", "1");
33813 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
33814 std::env::set_var("SLOC_RATE_LIMIT", "250");
33815 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
33816 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
33817 let cfg = load_runtime_security_config(true);
33818 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
33819 assert!(cfg.trust_proxy);
33820 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
33821 std::env::remove_var("SLOC_API_KEYS");
33822 std::env::remove_var("SLOC_TRUST_PROXY");
33823 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33824 std::env::remove_var("SLOC_RATE_LIMIT");
33825 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
33826 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
33827 }
33828
33829 #[test]
33830 fn cors_layer_builds_both_modes() {
33831 let _ = build_cors_layer(true);
33832 let _ = build_cors_layer(false);
33833 }
33834
33835 #[test]
33836 fn primary_lan_ip_callable() {
33837 let _ = primary_lan_ip();
33839 }
33840
33841 #[test]
33842 fn safe_redirect_allows_relative_rejects_absolute() {
33843 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
33844 assert_eq!(safe_redirect("https://evil.example/x"), "/");
33845 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
33846 assert_eq!(default_redirect(), "/view-reports");
33847 }
33848
33849 #[test]
33850 fn tarball_size_caps_env_override() {
33851 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
33852 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
33853 let (c, d) = parse_tarball_size_caps();
33854 assert_eq!(c, 1024 * 1024);
33855 assert_eq!(d, 2 * 1024 * 1024);
33856 std::env::remove_var("SLOC_MAX_TARBALL_MB");
33857 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
33858 let (c2, _) = parse_tarball_size_caps();
33859 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
33860 }
33861
33862 #[test]
33863 fn upload_path_helpers() {
33864 let base = upload_base_dir();
33865 let staged = upload_staging_path("abc123");
33866 assert!(staged.starts_with(&base));
33867 assert!(
33868 is_upload_tmp_path(&staged),
33869 "staging path is an upload tmp path"
33870 );
33871 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
33872 }
33873
33874 #[test]
33875 fn git_clones_dir_env_override() {
33876 std::env::remove_var("SLOC_GIT_CLONES_DIR");
33877 let def = resolve_git_clones_dir(Path::new("/out"));
33878 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
33879 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
33880 assert_eq!(
33881 resolve_git_clones_dir(Path::new("/out")),
33882 PathBuf::from("/custom/clones")
33883 );
33884 std::env::remove_var("SLOC_GIT_CLONES_DIR");
33885 }
33886
33887 #[test]
33888 fn html_report_file_detection() {
33889 let dir = std::env::temp_dir().join("sloc_html_detect");
33890 let _ = std::fs::create_dir_all(&dir);
33891 let good = dir.join("report_x.html");
33892 std::fs::write(&good, "<html></html>").unwrap();
33893 let bad = dir.join("notes.txt");
33894 std::fs::write(&bad, "x").unwrap();
33895 assert!(is_html_report_file(&good));
33896 assert!(!is_html_report_file(&bad));
33897 assert!(find_html_report_in_dir(&dir).is_some());
33898 let _ = std::fs::remove_dir_all(&dir);
33899 }
33900
33901 #[test]
33902 fn multi_delta_class_and_format() {
33903 assert_eq!(multi_delta_class(5), "pos");
33904 assert_eq!(multi_delta_class(-5), "neg");
33905 assert_eq!(multi_delta_class(0), "zero");
33906 assert_eq!(multi_fmt_delta(3), "+3");
33907 assert_eq!(multi_fmt_delta(-3), "-3");
33908 assert_eq!(multi_fmt_delta(0), "0");
33909 }
33910
33911 #[test]
33912 fn git_clone_dest_sanitizes() {
33913 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
33914 assert!(dest.starts_with("/clones"));
33915 let name = dest.file_name().unwrap().to_str().unwrap();
33916 assert!(name
33917 .chars()
33918 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
33919 }
33920}
33921
33922#[cfg(test)]
33923mod tests_private {
33924 use super::*;
33925 use std::io::Read;
33926
33927 #[test]
33928 fn size_limit_reader_zero_remaining_returns_error() {
33929 let data = b"hello world";
33930 let mut reader = SizeLimitReader {
33931 inner: &data[..],
33932 remaining: 0,
33933 };
33934 let mut buf = [0u8; 4];
33935 assert!(reader.read(&mut buf).is_err());
33936 }
33937
33938 #[test]
33939 fn size_limit_reader_counts_bytes() {
33940 let data = b"hello world";
33941 let mut reader = SizeLimitReader {
33942 inner: &data[..],
33943 remaining: 5,
33944 };
33945 let mut buf = [0u8; 4];
33946 let n = reader.read(&mut buf).unwrap();
33947 assert_eq!(n, 4);
33948 assert_eq!(reader.remaining, 1);
33949 }
33950
33951 #[test]
33952 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
33953 let uuid = "12345678-1234-1234-1234-123456789012";
33954 let (id, path) = resolve_or_create_staging(Some(uuid));
33955 assert_eq!(id, uuid);
33956 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
33957 }
33958
33959 #[test]
33960 fn resolve_or_create_staging_with_none_creates_new() {
33961 let (id1, _) = resolve_or_create_staging(None);
33962 let (id2, _) = resolve_or_create_staging(None);
33963 assert_ne!(id1, id2);
33964 }
33965
33966 #[test]
33967 fn resolve_or_create_staging_with_path_separator_creates_new() {
33968 let (id, _) = resolve_or_create_staging(Some("has/slash"));
33970 assert_ne!(id, "has/slash");
33971 }
33972
33973 #[test]
33974 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
33975 use std::net::IpAddr;
33976 use std::str::FromStr;
33977 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
33978 let ip = IpAddr::from_str("192.168.1.1").unwrap();
33979 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
33980 }
33981
33982 #[test]
33983 fn is_auth_locked_out_expired_entry_removed() {
33984 use std::net::IpAddr;
33985 use std::str::FromStr;
33986 let limiter = IpRateLimiter::new(
33987 Duration::from_mins(1),
33988 100,
33989 1, Duration::from_millis(1),
33991 );
33992 let ip = IpAddr::from_str("192.168.1.2").unwrap();
33993 limiter.record_auth_failure(ip);
33994 std::thread::sleep(Duration::from_millis(10));
33996 assert!(!limiter.is_auth_locked_out(ip));
33998 }
33999
34000 #[test]
34001 fn is_auth_locked_out_within_window_returns_true() {
34002 use std::net::IpAddr;
34003 use std::str::FromStr;
34004 let limiter = IpRateLimiter::new(
34005 Duration::from_mins(1),
34006 100,
34007 2, Duration::from_hours(1),
34009 );
34010 let ip = IpAddr::from_str("192.168.1.3").unwrap();
34011 limiter.record_auth_failure(ip);
34012 limiter.record_auth_failure(ip);
34013 assert!(limiter.is_auth_locked_out(ip));
34014 }
34015
34016 #[test]
34019 fn output_folder_hint_strips_json_subdir() {
34020 use std::path::Path;
34021 let path = Path::new("/output/scan1/json/result.json");
34022 let hint = output_folder_hint(path);
34023 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34024 }
34025
34026 #[test]
34027 fn output_folder_hint_strips_html_subdir() {
34028 use std::path::Path;
34029 let path = Path::new("/output/scan1/html/report.html");
34030 let hint = output_folder_hint(path);
34031 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34032 }
34033
34034 #[test]
34035 fn output_folder_hint_strips_pdf_subdir() {
34036 use std::path::Path;
34037 let path = Path::new("/output/scan1/pdf/report.pdf");
34038 let hint = output_folder_hint(path);
34039 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34040 }
34041
34042 #[test]
34043 fn output_folder_hint_strips_excel_subdir() {
34044 use std::path::Path;
34045 let path = Path::new("/output/scan1/excel/report.xlsx");
34046 let hint = output_folder_hint(path);
34047 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34048 }
34049
34050 #[test]
34051 fn output_folder_hint_flat_layout_returns_direct_parent() {
34052 use std::path::Path;
34053 let path = Path::new("/output/scan1/result.json");
34054 let hint = output_folder_hint(path);
34055 assert!(
34056 hint.ends_with("scan1"),
34057 "expected direct parent, got: {hint}"
34058 );
34059 }
34060
34061 #[test]
34062 fn output_folder_hint_other_subdir_name_not_stripped() {
34063 use std::path::Path;
34064 let path = Path::new("/output/scan1/data/result.json");
34066 let hint = output_folder_hint(path);
34067 assert!(
34068 hint.ends_with("data"),
34069 "non-artifact subdir must not be stripped, got: {hint}"
34070 );
34071 }
34072
34073 #[test]
34076 fn find_file_by_ext_finds_matching_file() {
34077 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34078 let _ = fs::create_dir_all(&dir);
34079 let f = dir.join("report.pdf");
34080 let _ = fs::write(&f, b"dummy");
34081 let result = find_file_by_ext(&dir, "pdf");
34082 assert!(result.is_some(), "expected to find report.pdf");
34083 let _ = fs::remove_dir_all(&dir);
34084 }
34085
34086 #[test]
34087 fn find_file_by_ext_returns_none_for_missing_ext() {
34088 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34089 let _ = fs::create_dir_all(&dir);
34090 let f = dir.join("report.json");
34091 let _ = fs::write(&f, b"{}");
34092 let result = find_file_by_ext(&dir, "pdf");
34093 assert!(result.is_none());
34094 let _ = fs::remove_dir_all(&dir);
34095 }
34096
34097 #[test]
34098 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34099 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34100 assert!(find_file_by_ext(dir, "json").is_none());
34101 }
34102
34103 #[test]
34106 fn collect_result_json_candidates_flat_root() {
34107 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34108 let _ = fs::create_dir_all(&root);
34109 let _ = fs::write(root.join("result.json"), b"{}");
34110 let candidates = collect_result_json_candidates(&root);
34111 assert!(!candidates.is_empty(), "should find result.json at root");
34112 let _ = fs::remove_dir_all(&root);
34113 }
34114
34115 #[test]
34116 fn collect_result_json_candidates_legacy_subdir() {
34117 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34118 let sub = root.join("scanA");
34119 let _ = fs::create_dir_all(&sub);
34120 let _ = fs::write(sub.join("result.json"), b"{}");
34121 let candidates = collect_result_json_candidates(&root);
34122 assert!(
34123 !candidates.is_empty(),
34124 "should find result.json in legacy subdir"
34125 );
34126 let _ = fs::remove_dir_all(&root);
34127 }
34128
34129 #[test]
34130 fn collect_result_json_candidates_structured_json_subdir() {
34131 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34132 let json_sub = root.join("scanB").join("json");
34133 let _ = fs::create_dir_all(&json_sub);
34134 let _ = fs::write(json_sub.join("result.json"), b"{}");
34135 let candidates = collect_result_json_candidates(&root);
34136 assert!(
34137 !candidates.is_empty(),
34138 "should find result.json inside <subdir>/json/"
34139 );
34140 let _ = fs::remove_dir_all(&root);
34141 }
34142
34143 #[test]
34144 fn collect_result_json_candidates_empty_dir() {
34145 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34146 let _ = fs::create_dir_all(&root);
34147 let candidates = collect_result_json_candidates(&root);
34148 assert!(candidates.is_empty());
34149 let _ = fs::remove_dir_all(&root);
34150 }
34151}