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, read_json, AnalysisRun, CleanupPolicy, CleanupPolicyStore,
72 FileChangeStatus, RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals,
73 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}
517
518#[derive(Clone)]
520enum AsyncRunState {
521 Running {
522 started_at: std::time::Instant,
523 cancel_token: Arc<std::sync::atomic::AtomicBool>,
524 phase: Arc<std::sync::Mutex<String>>,
525 files_done: Arc<std::sync::atomic::AtomicUsize>,
526 files_total: Arc<std::sync::atomic::AtomicUsize>,
527 },
528 Complete {
530 run_id: String,
531 },
532 Failed {
533 message: String,
534 },
535 Cancelled,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
541struct ScanProfile {
542 id: String,
543 name: String,
544 created_at: String,
545 params: serde_json::Value,
547}
548
549#[derive(Debug, Clone, Default, Serialize, Deserialize)]
550struct ScanProfileStore {
551 profiles: Vec<ScanProfile>,
552}
553
554impl ScanProfileStore {
555 fn load(path: &std::path::Path) -> Self {
556 fs::read_to_string(path)
557 .ok()
558 .and_then(|s| serde_json::from_str(&s).ok())
559 .unwrap_or_default()
560 }
561
562 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
563 if let Some(parent) = path.parent() {
564 fs::create_dir_all(parent)?;
565 }
566 let json = serde_json::to_string_pretty(self)?;
567 fs::write(path, json)?;
568 Ok(())
569 }
570}
571
572#[derive(Clone)]
573pub(crate) struct AppState {
574 pub(crate) base_config: AppConfig,
575 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
576 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
577 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
578 pub(crate) registry_path: PathBuf,
579 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
580 pub(crate) server_mode: bool,
581 pub(crate) tls_enabled: bool,
582 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
583 pub(crate) rate_limiter: Arc<IpRateLimiter>,
584 pub(crate) trust_proxy: bool,
585 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
588 pub(crate) git_clones_dir: PathBuf,
590 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
592 pub(crate) schedules_path: PathBuf,
593 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
595 pub(crate) scan_profiles_path: PathBuf,
596 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
597 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
599 pub(crate) confluence_path: PathBuf,
600 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
602 pub(crate) watched_dirs_path: PathBuf,
603 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
605 pub(crate) cleanup_policy_path: PathBuf,
606 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
608}
609
610type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
611
612#[derive(Clone, Debug)]
615pub(crate) struct RunArtifacts {
616 output_dir: PathBuf,
617 html_path: Option<PathBuf>,
618 pdf_path: Option<PathBuf>,
619 json_path: Option<PathBuf>,
620 csv_path: Option<PathBuf>,
621 xlsx_path: Option<PathBuf>,
622 scan_config_path: Option<PathBuf>,
623 report_title: String,
624 result_context: RunResultContext,
625}
626
627#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
629 let protected = Router::new()
630 .route("/", get(splash))
631 .route("/scan-setup", get(scan_setup_handler))
632 .route("/scan", get(index))
633 .route("/analyze", post(analyze_handler))
634 .route("/preview", get(preview_handler))
635 .route("/api/suggest-coverage", get(api_suggest_coverage))
636 .route("/pick-directory", get(pick_directory_handler))
637 .route("/open-path", get(open_path_handler))
638 .route("/pick-file", get(pick_file_handler))
639 .route(
640 "/api/upload-directory",
641 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
642 )
643 .route(
644 "/api/upload-file",
645 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
646 )
647 .route(
648 "/api/upload-tarball",
649 post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
650 )
651 .route("/locate-report", post(locate_report_handler))
652 .route("/locate-reports-dir", post(locate_reports_dir_handler))
653 .route("/relocate-scan", post(relocate_scan_handler))
654 .route("/watched-dirs/add", post(add_watched_dir_handler))
655 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
656 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
657 .route("/view-reports", get(history_handler))
658 .route("/compare-scans", get(compare_select_handler))
659 .route("/compare", get(compare_handler))
660 .route("/images/{folder}/{file}", get(image_handler))
661 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
662 .route("/api/metrics/latest", get(api_metrics_latest_handler))
663 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
664 .route("/api/metrics/history", get(api_metrics_history_handler))
665 .route(
666 "/api/metrics/submodules",
667 get(api_metrics_submodules_handler),
668 )
669 .route("/api/ingest", post(api_ingest_handler))
670 .route("/api/project-history", get(project_history_handler))
671 .route("/trend-reports", get(trend_report_handler))
672 .route("/test-metrics", get(test_metrics_handler))
673 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
674 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
675 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
676 .route("/runs/result/{run_id}", get(async_run_result_handler))
677 .route("/embed/summary", get(embed_handler))
678 .route("/git-browser", get(git_browser::git_browser_handler))
680 .route("/api/git/refs", get(git_browser::api_list_refs))
681 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
682 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
683 .route("/export-config", get(export_config_handler))
685 .route("/import-config", post(import_config_handler))
686 .route("/api/scan-profiles", get(api_list_scan_profiles))
688 .route("/api/scan-profiles", post(api_save_scan_profile))
689 .route(
690 "/api/scan-profiles/{id}",
691 axum::routing::delete(api_delete_scan_profile),
692 )
693 .route("/integrations", get(integrations::integrations_handler))
695 .route(
696 "/webhook-setup",
697 get(|| async { axum::response::Redirect::permanent("/integrations") }),
698 )
699 .route(
700 "/confluence-setup",
701 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
702 )
703 .route("/api/schedules", get(git_webhook::api_list_schedules))
704 .route("/api/schedules", post(git_webhook::api_create_schedule))
705 .route(
706 "/api/schedules",
707 axum::routing::delete(git_webhook::api_delete_schedule),
708 )
709 .route(
710 "/api/confluence/config",
711 get(confluence::api_get_confluence_config),
712 )
713 .route(
714 "/api/confluence/config",
715 post(confluence::api_save_confluence_config),
716 )
717 .route(
718 "/api/confluence/test",
719 post(confluence::api_test_confluence),
720 )
721 .route(
722 "/api/confluence/post",
723 post(confluence::api_post_to_confluence),
724 )
725 .route(
726 "/api/confluence/wiki-markup",
727 get(confluence::api_wiki_markup),
728 )
729 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
731 .route(
732 "/api/runs/{run_id}",
733 axum::routing::delete(delete_run_handler),
734 )
735 .route("/api/runs/cleanup", post(cleanup_runs_handler))
736 .route(
738 "/api/cleanup-policy",
739 get(api_get_cleanup_policy)
740 .post(api_save_cleanup_policy)
741 .delete(api_delete_cleanup_policy),
742 )
743 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
744 .route("/api-docs", get(api_docs_handler))
746 .route("/metrics", get(metrics_handler))
748 .route_layer(middleware::from_fn_with_state(
749 state.clone(),
750 auth::require_api_key,
751 ));
752
753 protected
754 .route("/healthz", get(healthz))
755 .route("/api/health", get(healthz))
756 .route("/api/version", get(api_version_handler))
757 .route("/api/openapi.yaml", get(openapi_yaml_handler))
758 .route("/badge/{metric}", get(badge_handler))
759 .route("/static/chart.js", get(chart_js_handler))
760 .route("/static/chart-report.js", get(report_chart_js_handler))
761 .route("/auth/login", get(auth::auth_login_get))
762 .route("/auth/login", post(auth::auth_login_post))
763 .route(
766 "/webhooks/github",
767 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
768 )
769 .route(
770 "/webhooks/gitlab",
771 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
772 )
773 .route(
774 "/webhooks/bitbucket",
775 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
776 )
777 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
778 .layer(middleware::from_fn_with_state(
779 state.clone(),
780 add_security_headers,
781 ))
782 .layer(build_cors_layer(state.server_mode))
783 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
784 .with_state(state)
785}
786
787pub fn make_test_router() -> Router {
789 std::env::set_var("SLOC_HEADLESS", "1");
791 let tmp = std::env::temp_dir().join("sloc_test");
792 let state = AppState {
793 base_config: AppConfig::default(),
794 artifacts: Arc::new(Mutex::new(HashMap::new())),
795 async_runs: Arc::new(Mutex::new(HashMap::new())),
796 registry: Arc::new(Mutex::new(ScanRegistry::default())),
797 registry_path: tmp.join("registry.json"),
798 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
799 server_mode: false,
800 tls_enabled: false,
801 api_keys: Arc::new(vec![]),
802 rate_limiter: Arc::new(IpRateLimiter::new(
803 Duration::from_mins(1),
804 600,
805 10,
806 Duration::from_hours(1),
807 )),
808 trust_proxy: false,
809 trusted_proxy_ips: vec![],
810 git_clones_dir: tmp.join("git-clones"),
811 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
812 schedules_path: tmp.join("schedules.json"),
813 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
814 scan_profiles_path: tmp.join("scan_profiles.json"),
815 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
816 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
817 confluence_path: tmp.join("confluence_config.json"),
818 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
819 watched_dirs_path: tmp.join("watched_dirs.json"),
820 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
821 cleanup_policy_path: tmp.join("cleanup_policy.json"),
822 cleanup_task_handle: Arc::new(Mutex::new(None)),
823 };
824 build_router(state)
825}
826
827pub fn make_test_router_with_key(api_key: &str) -> Router {
829 let tmp = std::env::temp_dir().join("sloc_test_key");
830 let state = AppState {
831 base_config: AppConfig::default(),
832 artifacts: Arc::new(Mutex::new(HashMap::new())),
833 async_runs: Arc::new(Mutex::new(HashMap::new())),
834 registry: Arc::new(Mutex::new(ScanRegistry::default())),
835 registry_path: tmp.join("registry.json"),
836 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
837 server_mode: false,
838 tls_enabled: false,
839 api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
840 rate_limiter: Arc::new(IpRateLimiter::new(
841 Duration::from_mins(1),
842 600,
843 10,
844 Duration::from_hours(1),
845 )),
846 trust_proxy: false,
847 trusted_proxy_ips: vec![],
848 git_clones_dir: tmp.join("git-clones"),
849 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
850 schedules_path: tmp.join("schedules.json"),
851 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
852 scan_profiles_path: tmp.join("scan_profiles.json"),
853 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
854 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
855 confluence_path: tmp.join("confluence_config.json"),
856 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
857 watched_dirs_path: tmp.join("watched_dirs.json"),
858 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
859 cleanup_policy_path: tmp.join("cleanup_policy.json"),
860 cleanup_task_handle: Arc::new(Mutex::new(None)),
861 };
862 build_router(state)
863}
864
865struct RuntimeSecurityConfig {
866 api_keys: Vec<secrecy::SecretBox<String>>,
867 tls_cert: Option<String>,
868 tls_key: Option<String>,
869 tls_enabled: bool,
870 trust_proxy: bool,
871 trusted_proxy_ips: Vec<IpAddr>,
872 rate_limiter: Arc<IpRateLimiter>,
873}
874
875fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
876 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
877 .or_else(|_| std::env::var("SLOC_API_KEY"))
878 .unwrap_or_default()
879 .split(',')
880 .map(str::trim)
881 .filter(|s| !s.is_empty())
882 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
883 .collect();
884 if server_mode && api_keys.is_empty() {
885 println!(
886 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
887 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
888 );
889 }
890 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
891 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
892 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
893 if server_mode && !tls_enabled {
894 println!(
895 "WARNING: TLS is not configured. Traffic is cleartext. \
896 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
897 or terminate TLS at a reverse proxy (nginx, caddy)."
898 );
899 }
900 if server_mode {
901 println!(
902 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
903 to restrict cross-origin access (comma-separated)."
904 );
905 }
906 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
907 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
908 .unwrap_or_default()
909 .split(',')
910 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
911 .collect();
912 if trust_proxy {
913 if trusted_proxy_ips.is_empty() {
914 println!(
915 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
916 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
917 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
918 );
919 } else {
920 println!(
921 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
922 trusted_proxy_ips
923 .iter()
924 .map(std::string::ToString::to_string)
925 .collect::<Vec<_>>()
926 .join(", ")
927 );
928 }
929 } else if server_mode {
930 println!(
931 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
932 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
933 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
934 enable per-client rate limiting via X-Forwarded-For."
935 );
936 }
937 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
938 println!(
939 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
940 DISABLED for all git operations. Remove this variable before production use."
941 );
942 }
943 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
944 .ok()
945 .and_then(|v| v.parse::<u32>().ok())
946 .unwrap_or(10);
947 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
948 .ok()
949 .and_then(|v| v.parse::<u64>().ok())
950 .unwrap_or(3600);
951 let default_rpm: usize = if server_mode { 120 } else { 600 };
955 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
956 .ok()
957 .and_then(|v| v.parse::<usize>().ok())
958 .unwrap_or(default_rpm);
959 let rate_limiter = Arc::new(IpRateLimiter::new(
960 Duration::from_mins(1),
961 rate_limit_rpm,
962 auth_lockout_threshold,
963 Duration::from_secs(auth_lockout_secs),
964 ));
965 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
966 RuntimeSecurityConfig {
967 api_keys,
968 tls_cert,
969 tls_key,
970 tls_enabled,
971 trust_proxy,
972 trusted_proxy_ips,
973 rate_limiter,
974 }
975}
976
977#[allow(clippy::too_many_lines)]
986pub async fn serve(config: AppConfig) -> Result<()> {
987 let bind_address = config.web.bind_address.clone();
988 let server_mode = config.web.server_mode;
989 let output_root = resolve_output_root(None);
990 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
992 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
993 let mut registry = ScanRegistry::load(®istry_path);
994 registry.prune_stale();
995 let _ = registry.save(®istry_path);
996
997 let sec = load_runtime_security_config(server_mode);
998 spawn_upload_staging_cleanup();
999
1000 let git_clones_dir = resolve_git_clones_dir(&output_root);
1001 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1002 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1003 let schedules = ScheduleStore::load(&schedules_path);
1004 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1005 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1006 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1007 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1008 |_| output_root.join("confluence_config.json"),
1009 PathBuf::from,
1010 );
1011 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1012 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1013 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1014 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1015 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1016 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1017 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1018
1019 let state = AppState {
1020 base_config: config,
1021 artifacts: Arc::new(Mutex::new(HashMap::new())),
1022 async_runs: Arc::new(Mutex::new(HashMap::new())),
1023 registry: Arc::new(Mutex::new(registry)),
1024 registry_path,
1025 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1026 server_mode,
1027 tls_enabled: sec.tls_enabled,
1028 api_keys: Arc::new(sec.api_keys),
1029 rate_limiter: sec.rate_limiter,
1030 trust_proxy: sec.trust_proxy,
1031 trusted_proxy_ips: sec.trusted_proxy_ips,
1032 git_clones_dir,
1033 schedules: Arc::new(Mutex::new(schedules)),
1034 schedules_path,
1035 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1036 scan_profiles_path,
1037 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1038 confluence: Arc::new(Mutex::new(confluence)),
1039 confluence_path,
1040 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1041 watched_dirs_path,
1042 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1043 cleanup_policy_path,
1044 cleanup_task_handle: Arc::new(Mutex::new(None)),
1045 };
1046
1047 restart_poll_schedules(&state).await;
1048
1049 {
1051 let enabled = state
1052 .cleanup_policy
1053 .lock()
1054 .await
1055 .policy
1056 .as_ref()
1057 .is_some_and(|p| p.enabled);
1058 if enabled {
1059 let handle = spawn_cleanup_policy_task(state.clone());
1060 *state.cleanup_task_handle.lock().await = Some(handle);
1061 }
1062 }
1063
1064 let app = build_router(state.clone());
1065
1066 let preferred: SocketAddr = bind_address
1071 .parse()
1072 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1073 let (listener, addr) = {
1074 let candidates = (0u16..=9).map(|offset| {
1075 let mut a = preferred;
1076 a.set_port(preferred.port().saturating_add(offset));
1077 a
1078 });
1079 let mut found = None;
1080 for candidate in candidates {
1081 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1082 found = Some((l, candidate));
1083 break;
1084 }
1085 }
1086 found.ok_or_else(|| {
1087 anyhow::anyhow!(
1088 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1089 bind_address,
1090 preferred.port(),
1091 preferred.port().saturating_add(9)
1092 )
1093 })?
1094 };
1095 if addr != preferred {
1096 eprintln!(
1097 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1098 using {} instead.",
1099 preferred.port(),
1100 addr.port()
1101 );
1102 }
1103
1104 if sec.tls_enabled {
1105 let cert_path = sec
1106 .tls_cert
1107 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1108 let key_path = sec
1109 .tls_key
1110 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1111 let tls_config = build_tls_config(&cert_path, &key_path)
1112 .context("failed to load TLS certificate/key")?;
1113 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1114
1115 let url = format!("https://{addr}/");
1116 println!("OxideSLOC server running at {url} (TLS)");
1117 println!("Use Ctrl+C to stop.");
1118
1119 return serve_tls(listener, app, acceptor, server_mode).await;
1120 }
1121
1122 let url = format!("http://{addr}/");
1123 log_startup_url(&url, server_mode);
1124
1125 axum::serve(
1126 listener,
1127 app.into_make_service_with_connect_info::<SocketAddr>(),
1128 )
1129 .with_graceful_shutdown(shutdown_signal(server_mode))
1130 .await
1131 .context("web server terminated unexpectedly")
1132}
1133
1134fn primary_lan_ip() -> Option<String> {
1138 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1139 socket.connect("8.8.8.8:80").ok()?;
1140 let addr = socket.local_addr().ok()?;
1141 let ip = addr.ip();
1142 if ip.is_loopback() {
1143 return None;
1144 }
1145 Some(ip.to_string())
1146}
1147
1148fn log_startup_url(url: &str, server_mode: bool) {
1150 if server_mode {
1151 println!("OxideSLOC server running at {url}");
1152 println!("Use Ctrl+C to stop.");
1153 } else {
1154 println!("OxideSLOC local web UI running at {url}");
1155 println!("Press Ctrl+C to stop the server.");
1156 let open_url = url.to_owned();
1157 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1158 }
1159}
1160
1161fn open_browser_tab(url: &str) {
1163 #[cfg(target_os = "windows")]
1164 let _ = std::process::Command::new("cmd")
1165 .args(["/c", "start", "", url])
1166 .stdout(Stdio::null())
1167 .stderr(Stdio::null())
1168 .spawn();
1169 #[cfg(target_os = "macos")]
1170 let _ = std::process::Command::new("open")
1171 .arg(url)
1172 .stdout(Stdio::null())
1173 .stderr(Stdio::null())
1174 .spawn();
1175 #[cfg(target_os = "linux")]
1176 let _ = std::process::Command::new("xdg-open")
1177 .arg(url)
1178 .stdout(Stdio::null())
1179 .stderr(Stdio::null())
1180 .spawn();
1181}
1182
1183async fn shutdown_signal(server_mode: bool) {
1185 if tokio::signal::ctrl_c().await.is_ok() {
1186 println!();
1187 if server_mode {
1188 println!("Shutting down OxideSLOC server...");
1189 } else {
1190 println!("Shutting down OxideSLOC local web UI...");
1191 }
1192 println!("Server stopped cleanly.");
1193 }
1194}
1195
1196fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1198 use rustls_pki_types::pem::PemObject;
1199 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1200
1201 let cert_bytes =
1202 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1203 let key_bytes =
1204 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1205
1206 let cert_chain: Vec<CertificateDer<'static>> =
1207 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1208 .collect::<std::result::Result<_, _>>()
1209 .context("failed to parse TLS certificates")?;
1210
1211 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1212 .context("failed to parse TLS private key")?;
1213
1214 rustls::ServerConfig::builder()
1215 .with_no_client_auth()
1216 .with_single_cert(cert_chain, key)
1217 .context("failed to build TLS server config")
1218}
1219
1220async fn serve_tls(
1222 listener: tokio::net::TcpListener,
1223 app: Router,
1224 acceptor: tokio_rustls::TlsAcceptor,
1225 server_mode: bool,
1226) -> Result<()> {
1227 use hyper_util::rt::{TokioExecutor, TokioIo};
1228 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1229 use hyper_util::service::TowerToHyperService;
1230 use tower::{Service, ServiceExt};
1231
1232 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1233
1234 loop {
1235 tokio::select! {
1236 biased;
1237 _ = tokio::signal::ctrl_c() => {
1238 println!();
1239 if server_mode {
1240 println!("Shutting down OxideSLOC server...");
1241 } else {
1242 println!("Shutting down OxideSLOC local web UI...");
1243 }
1244 println!("Server stopped cleanly.");
1245 return Ok(());
1246 }
1247 result = listener.accept() => {
1248 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1249 let acceptor = acceptor.clone();
1250 let mut factory = make_svc.clone();
1251
1252 tokio::spawn(async move {
1253 let tls = match acceptor.accept(tcp).await {
1254 Ok(s) => s,
1255 Err(e) => {
1256 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1257 return;
1258 }
1259 };
1260 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1261 Ok(f) => match Service::call(f, peer_addr).await {
1262 Ok(s) => s,
1263 Err(_) => return,
1264 },
1265 Err(_) => return,
1266 };
1267 let io = TokioIo::new(tls);
1268 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1269 .serve_connection(io, TowerToHyperService::new(svc))
1270 .await
1271 {
1272 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1273 }
1274 });
1275 }
1276 }
1277 }
1278}
1279
1280fn build_cors_layer(server_mode: bool) -> CorsLayer {
1283 if server_mode {
1284 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1285 .unwrap_or_default()
1286 .split(',')
1287 .filter(|s| !s.is_empty())
1288 .filter_map(|s| s.trim().parse().ok())
1289 .collect();
1290 if allowed.is_empty() {
1291 return CorsLayer::new();
1292 }
1293 CorsLayer::new()
1294 .allow_origin(AllowOrigin::list(allowed))
1295 .allow_methods(AllowMethods::list([
1296 axum::http::Method::GET,
1297 axum::http::Method::POST,
1298 ]))
1299 .allow_headers(AllowHeaders::list([
1300 axum::http::header::AUTHORIZATION,
1301 axum::http::header::CONTENT_TYPE,
1302 ]))
1303 } else {
1304 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1305 let s = origin.to_str().unwrap_or("");
1306 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1307 }))
1308 }
1309}
1310
1311async fn add_security_headers(
1312 State(state): State<AppState>,
1313 mut req: Request<Body>,
1314 next: Next,
1315) -> Response {
1316 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1317 req.extensions_mut().insert(CspNonce(nonce.clone()));
1318 let mut resp = next.run(req).await;
1319 let h = resp.headers_mut();
1320 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1321 h.insert(
1322 "X-Content-Type-Options",
1323 HeaderValue::from_static("nosniff"),
1324 );
1325 h.insert(
1326 "Referrer-Policy",
1327 HeaderValue::from_static("strict-origin-when-cross-origin"),
1328 );
1329 let csp = format!(
1330 "default-src 'self'; \
1331 style-src 'self' 'unsafe-inline'; \
1332 img-src 'self' data: blob:; \
1333 script-src 'self' 'nonce-{nonce}'; \
1334 font-src 'self' data:; \
1335 object-src 'none'; \
1336 frame-ancestors 'none'"
1337 );
1338 h.insert(
1339 "Content-Security-Policy",
1340 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1341 HeaderValue::from_static(
1342 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1343 )
1344 }),
1345 );
1346 h.insert(
1347 "X-Permitted-Cross-Domain-Policies",
1348 HeaderValue::from_static("none"),
1349 );
1350 h.insert(
1351 "Permissions-Policy",
1352 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1353 );
1354 h.insert(
1355 "Cross-Origin-Opener-Policy",
1356 HeaderValue::from_static("same-origin"),
1357 );
1358 h.insert(
1359 "Cross-Origin-Resource-Policy",
1360 HeaderValue::from_static("same-origin"),
1361 );
1362 if state.tls_enabled {
1363 h.insert(
1364 "Strict-Transport-Security",
1365 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1366 );
1367 }
1368 resp
1369}
1370
1371async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1372 let peer_ip = req
1373 .extensions()
1374 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1375 .map(|c| c.0.ip());
1376
1377 let ip = peer_ip
1381 .and_then(|peer| {
1382 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1383 req.headers()
1384 .get("X-Forwarded-For")
1385 .and_then(|v| v.to_str().ok())
1386 .and_then(|s| s.split(',').next())
1387 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1388 } else {
1389 None
1390 }
1391 })
1392 .or(peer_ip)
1393 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1394
1395 if !state.rate_limiter.is_allowed(ip) {
1396 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1397 path = %req.uri().path(), "Rate limit exceeded");
1398 return (
1399 StatusCode::TOO_MANY_REQUESTS,
1400 [(header::RETRY_AFTER, "60")],
1401 "429 Too Many Requests\n",
1402 )
1403 .into_response();
1404 }
1405 next.run(req).await
1406}
1407
1408async fn splash(
1409 State(state): State<AppState>,
1410 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1411) -> impl IntoResponse {
1412 let lan_ip = if state.server_mode {
1413 primary_lan_ip()
1414 } else {
1415 None
1416 };
1417 let port = state
1418 .base_config
1419 .web
1420 .bind_address
1421 .rsplit(':')
1422 .next()
1423 .and_then(|p| p.parse::<u16>().ok())
1424 .unwrap_or(4317);
1425 let has_api_key = !state.api_keys.is_empty();
1426 let template = SplashTemplate {
1427 csp_nonce,
1428 server_mode: state.server_mode,
1429 lan_ip,
1430 port,
1431 version: env!("CARGO_PKG_VERSION"),
1432 has_api_key,
1433 };
1434 Html(
1435 template
1436 .render()
1437 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1438 )
1439}
1440
1441async fn index(
1442 State(state): State<AppState>,
1443 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1444 Query(query): Query<IndexQuery>,
1445) -> impl IntoResponse {
1446 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1447 let policy = query
1448 .mixed_line_policy
1449 .unwrap_or_else(|| "code_only".to_string());
1450 let behavior = query
1451 .binary_file_behavior
1452 .unwrap_or_else(|| "skip".to_string());
1453 let cfg = ScanConfig {
1454 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1455 path: query.path.unwrap_or_default(),
1456 include_globs: query.include_globs.unwrap_or_default(),
1457 exclude_globs: query.exclude_globs.unwrap_or_default(),
1458 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1459 mixed_line_policy: policy,
1460 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1461 != Some("off"),
1462 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1463 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1464 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1465 != Some("disabled"),
1466 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1467 binary_file_behavior: behavior,
1468 output_dir: query.output_dir.unwrap_or_default(),
1469 report_title: query.report_title.unwrap_or_default(),
1470 };
1471 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1472 } else {
1473 "{}".to_string()
1474 };
1475
1476 let git_repo = query.git_repo.unwrap_or_default();
1477 let git_ref = query.git_ref.unwrap_or_default();
1478
1479 let git_label = make_git_label(&git_repo, &git_ref);
1480 let git_output_dir = if git_label.is_empty() {
1481 String::new()
1482 } else {
1483 desktop_dir().join(&git_label).display().to_string()
1484 };
1485 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1486 let git_output_dir_json =
1487 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1488
1489 let template = IndexTemplate {
1490 version: env!("CARGO_PKG_VERSION"),
1491 prefill_json,
1492 csp_nonce,
1493 git_repo,
1494 git_ref,
1495 git_label_json,
1496 git_output_dir_json,
1497 server_mode: state.server_mode,
1498 };
1499
1500 Html(
1501 template
1502 .render()
1503 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1504 )
1505}
1506
1507async fn scan_setup_handler(
1508 State(state): State<AppState>,
1509 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1510) -> impl IntoResponse {
1511 let recent_scans_json = {
1512 let arr: Vec<serde_json::Value> = {
1513 let reg = state.registry.lock().await;
1514 reg.entries
1515 .iter()
1516 .rev()
1517 .take(6)
1518 .map(|e| {
1519 let run_dir = e
1520 .html_path
1521 .as_ref()
1522 .or(e.json_path.as_ref())
1523 .and_then(|p| p.parent().map(PathBuf::from));
1524 let config_val: Option<serde_json::Value> = run_dir
1525 .and_then(|d| find_scan_config_in_dir(&d))
1526 .and_then(|p| fs::read_to_string(&p).ok())
1527 .and_then(|s| serde_json::from_str(&s).ok());
1528 serde_json::json!({
1529 "project_label": e.project_label,
1530 "timestamp": fmt_la_time(e.timestamp_utc),
1531 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1532 "config": config_val,
1533 })
1534 })
1535 .collect()
1536 };
1537 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1538 };
1539
1540 let template = ScanSetupTemplate {
1541 version: env!("CARGO_PKG_VERSION"),
1542 recent_scans_json,
1543 csp_nonce,
1544 };
1545 Html(
1546 template
1547 .render()
1548 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1549 )
1550}
1551
1552async fn healthz() -> &'static str {
1553 "ok"
1554}
1555
1556async fn api_version_handler() -> impl IntoResponse {
1557 axum::Json(serde_json::json!({
1558 "name": "oxide-sloc",
1559 "version": env!("CARGO_PKG_VERSION"),
1560 }))
1561}
1562
1563fn prom_runs_total() -> &'static prometheus::IntCounter {
1566 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1567 COUNTER.get_or_init(|| {
1568 prometheus::register_int_counter!(
1569 "oxide_sloc_runs_total",
1570 "Total number of completed analysis runs"
1571 )
1572 .expect("failed to register oxide_sloc_runs_total counter")
1573 })
1574}
1575
1576async fn metrics_handler() -> impl IntoResponse {
1577 use prometheus::Encoder as _;
1578 let mut buf = Vec::new();
1579 let encoder = prometheus::TextEncoder::new();
1580 let _ = encoder.encode(&prometheus::gather(), &mut buf);
1581 (
1582 [(
1583 axum::http::header::CONTENT_TYPE,
1584 "text/plain; version=0.0.4; charset=utf-8",
1585 )],
1586 buf,
1587 )
1588}
1589
1590static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1591
1592async fn openapi_yaml_handler() -> impl IntoResponse {
1593 (
1594 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1595 OPENAPI_YAML,
1596 )
1597}
1598
1599async fn api_docs_handler(
1600 State(state): State<AppState>,
1601 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1602) -> impl IntoResponse {
1603 let has_api_key = !state.api_keys.is_empty();
1604 Html(
1605 ApiDocsTemplate {
1606 has_api_key,
1607 csp_nonce,
1608 version: env!("CARGO_PKG_VERSION"),
1609 }
1610 .render()
1611 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1612 )
1613}
1614
1615async fn chart_js_handler() -> impl IntoResponse {
1616 (
1617 [
1618 (
1619 header::CONTENT_TYPE,
1620 "application/javascript; charset=utf-8",
1621 ),
1622 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1623 ],
1624 CHART_JS,
1625 )
1626}
1627
1628async fn report_chart_js_handler() -> impl IntoResponse {
1629 (
1630 [
1631 (
1632 header::CONTENT_TYPE,
1633 "application/javascript; charset=utf-8",
1634 ),
1635 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1636 ],
1637 REPORT_CHART_JS,
1638 )
1639}
1640
1641#[derive(Debug, Deserialize)]
1642struct AnalyzeForm {
1643 path: String,
1644 git_repo: Option<String>,
1645 git_ref: Option<String>,
1646 mixed_line_policy: Option<MixedLinePolicy>,
1647 python_docstrings_as_comments: Option<String>,
1648 generated_file_detection: Option<String>,
1649 minified_file_detection: Option<String>,
1650 vendor_directory_detection: Option<String>,
1651 include_lockfiles: Option<String>,
1652 binary_file_behavior: Option<BinaryFileBehavior>,
1653 output_dir: Option<String>,
1654 report_title: Option<String>,
1655 report_header_footer: Option<String>,
1656 include_globs: Option<String>,
1657 exclude_globs: Option<String>,
1658 submodule_breakdown: Option<String>,
1659 coverage_file: Option<String>,
1660 continuation_line_policy: Option<ContinuationLinePolicy>,
1661 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1662 count_compiler_directives: Option<String>,
1663 style_col_threshold: Option<String>,
1664 style_analysis_enabled: Option<String>,
1665 style_score_threshold: Option<String>,
1666 style_lang_scope: Option<String>,
1667}
1668
1669#[allow(clippy::struct_excessive_bools)]
1670#[derive(Debug, Serialize, Deserialize, Clone)]
1671struct ScanConfig {
1672 oxide_sloc_version: String,
1673 path: String,
1674 include_globs: String,
1675 exclude_globs: String,
1676 submodule_breakdown: bool,
1677 mixed_line_policy: String,
1678 python_docstrings_as_comments: bool,
1679 generated_file_detection: bool,
1680 minified_file_detection: bool,
1681 vendor_directory_detection: bool,
1682 include_lockfiles: bool,
1683 binary_file_behavior: String,
1684 output_dir: String,
1685 report_title: String,
1686}
1687
1688#[derive(Debug, Deserialize, Default)]
1689struct IndexQuery {
1690 path: Option<String>,
1691 include_globs: Option<String>,
1692 exclude_globs: Option<String>,
1693 submodule_breakdown: Option<String>,
1694 mixed_line_policy: Option<String>,
1695 python_docstrings_as_comments: Option<String>,
1696 generated_file_detection: Option<String>,
1697 minified_file_detection: Option<String>,
1698 vendor_directory_detection: Option<String>,
1699 include_lockfiles: Option<String>,
1700 binary_file_behavior: Option<String>,
1701 output_dir: Option<String>,
1702 report_title: Option<String>,
1703 prefilled: Option<String>,
1704 git_repo: Option<String>,
1705 git_ref: Option<String>,
1706}
1707
1708#[derive(Debug, Deserialize)]
1709struct PreviewQuery {
1710 path: Option<String>,
1711 include_globs: Option<String>,
1712 exclude_globs: Option<String>,
1713}
1714
1715#[cfg(feature = "native-dialog")]
1716#[derive(Debug, Deserialize)]
1717struct PickDirectoryQuery {
1718 kind: Option<String>,
1719 current: Option<String>,
1720}
1721
1722#[cfg(not(feature = "native-dialog"))]
1723#[derive(Debug, Deserialize)]
1724struct PickDirectoryQuery {}
1725
1726#[derive(Debug, Deserialize, Default)]
1727struct ArtifactQuery {
1728 download: Option<String>,
1729}
1730
1731#[cfg(feature = "native-dialog")]
1732#[derive(Debug, Serialize)]
1733struct PickDirectoryResponse {
1734 selected_path: Option<String>,
1735 cancelled: bool,
1736}
1737
1738#[cfg(feature = "native-dialog")]
1739async fn pick_directory_handler(
1740 State(state): State<AppState>,
1741 Query(query): Query<PickDirectoryQuery>,
1742) -> Response {
1743 if state.server_mode {
1744 return StatusCode::NOT_FOUND.into_response();
1745 }
1746 if std::env::var("SLOC_HEADLESS").is_ok() {
1748 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1749 .into_response();
1750 }
1751
1752 let is_coverage = query.kind.as_deref() == Some("coverage");
1753 let title = match query.kind.as_deref() {
1754 Some("output") => "Select output directory",
1755 Some("reports") => "Select folder containing saved reports",
1756 Some("coverage") => "Select LCOV coverage file",
1757 _ => "Select project directory",
1758 }
1759 .to_owned();
1760 let current = query.current.clone();
1761
1762 let picked = tokio::task::spawn_blocking(move || {
1763 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1766 let fg_tid = win_dialog_focus::attach_to_foreground();
1767 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1768 win_dialog_focus::flash_dialog_when_ready(title.clone());
1769
1770 let mut dialog = rfd::FileDialog::new().set_title(&title);
1771 if let Some(current) = current.as_deref() {
1772 let resolved = resolve_input_path(current);
1773 let seed = if resolved.is_dir() {
1774 Some(resolved)
1775 } else {
1776 resolved.parent().map(Path::to_path_buf)
1777 };
1778 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1779 dialog = dialog.set_directory(seed_dir);
1780 }
1781 }
1782 let result = if is_coverage {
1783 dialog
1784 .add_filter(
1785 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1786 &["info", "lcov", "xml"],
1787 )
1788 .pick_file()
1789 } else {
1790 dialog.pick_folder()
1791 };
1792
1793 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1794 win_dialog_focus::detach_from_foreground(fg_tid);
1795
1796 result
1797 })
1798 .await
1799 .unwrap_or(None);
1800
1801 Json(PickDirectoryResponse {
1802 selected_path: picked.as_ref().map(|p| display_path(p)),
1803 cancelled: picked.is_none(),
1804 })
1805 .into_response()
1806}
1807
1808#[cfg(not(feature = "native-dialog"))]
1809async fn pick_directory_handler(
1810 State(_state): State<AppState>,
1811 Query(_query): Query<PickDirectoryQuery>,
1812) -> Response {
1813 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1814}
1815
1816#[cfg(feature = "native-dialog")]
1817async fn pick_file_handler(State(state): State<AppState>) -> Response {
1818 if state.server_mode {
1819 return StatusCode::NOT_FOUND.into_response();
1820 }
1821 if std::env::var("SLOC_HEADLESS").is_ok() {
1822 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1823 .into_response();
1824 }
1825 let picked = tokio::task::spawn_blocking(|| {
1826 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1827 let fg_tid = win_dialog_focus::attach_to_foreground();
1828 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1829 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1830
1831 let result = rfd::FileDialog::new()
1832 .set_title("Select HTML report")
1833 .add_filter("HTML report", &["html"])
1834 .pick_file();
1835
1836 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1837 win_dialog_focus::detach_from_foreground(fg_tid);
1838
1839 result
1840 })
1841 .await
1842 .unwrap_or(None);
1843 Json(PickDirectoryResponse {
1844 selected_path: picked.as_ref().map(|p| display_path(p)),
1845 cancelled: picked.is_none(),
1846 })
1847 .into_response()
1848}
1849
1850#[cfg(not(feature = "native-dialog"))]
1851async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1852 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1853}
1854
1855fn is_upload_tmp_path(path: &Path) -> bool {
1860 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
1861 path.starts_with(&upload_root)
1862}
1863
1864fn is_sample_path(path: &Path) -> bool {
1867 let root = workspace_root();
1868 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
1869}
1870
1871fn upload_base_dir() -> PathBuf {
1873 std::env::temp_dir().join("oxide-sloc-uploads")
1874}
1875
1876fn upload_staging_path(id: &str) -> PathBuf {
1878 upload_base_dir().join(id)
1879}
1880
1881#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
1885 const MAX_FILES: usize = 50_000;
1886 if body.files.is_empty() {
1887 return Err((
1888 StatusCode::BAD_REQUEST,
1889 Json(serde_json::json!({"error": "No files received"})),
1890 )
1891 .into_response());
1892 }
1893 if body.files.len() > MAX_FILES {
1894 return Err((
1895 StatusCode::PAYLOAD_TOO_LARGE,
1896 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
1897 )
1898 .into_response());
1899 }
1900 Ok(())
1901}
1902
1903fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
1906 match id {
1907 Some(id)
1908 if !id.is_empty()
1909 && id.len() <= 36
1910 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
1911 {
1912 (id.to_string(), upload_staging_path(id))
1913 }
1914 _ => {
1915 let new_id = uuid::Uuid::new_v4().to_string();
1916 let staging = upload_staging_path(&new_id);
1917 (new_id, staging)
1918 }
1919 }
1920}
1921
1922#[allow(clippy::result_large_err)]
1927async fn stage_decoded_entry(
1928 entry: &UploadedFile,
1929 staging: &Path,
1930 total_bytes: &mut usize,
1931 project_root: &mut Option<PathBuf>,
1932) -> Result<(), Response> {
1933 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
1934
1935 let Ok(data) = base64::Engine::decode(
1936 &base64::engine::general_purpose::STANDARD,
1937 entry.content.as_bytes(),
1938 ) else {
1939 return Ok(());
1940 };
1941
1942 *total_bytes += data.len();
1943 if *total_bytes > MAX_TOTAL_BYTES {
1944 return Err((
1945 StatusCode::PAYLOAD_TOO_LARGE,
1946 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
1947 )
1948 .into_response());
1949 }
1950
1951 let rel = std::path::Path::new(&entry.path);
1952 if project_root.is_none() {
1953 if let Some(first) = rel.components().next() {
1954 *project_root = Some(staging.join(first.as_os_str()));
1955 }
1956 }
1957
1958 let dest = staging.join(rel);
1959 if let Some(parent) = dest.parent() {
1960 if tokio::fs::create_dir_all(parent).await.is_err() {
1961 return Err((
1962 StatusCode::INTERNAL_SERVER_ERROR,
1963 Json(serde_json::json!({"error": "Failed to create directory structure"})),
1964 )
1965 .into_response());
1966 }
1967 }
1968
1969 if tokio::fs::write(&dest, &data).await.is_err() {
1970 return Err((
1971 StatusCode::INTERNAL_SERVER_ERROR,
1972 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
1973 )
1974 .into_response());
1975 }
1976
1977 Ok(())
1978}
1979
1980async fn write_upload_files(
1984 files: &[UploadedFile],
1985 staging: &Path,
1986 upload_id: &str,
1987) -> Result<(usize, Option<PathBuf>), Response> {
1988 let mut total_bytes: usize = 0;
1989 let mut project_root: Option<PathBuf> = None;
1990 let mut traversal_attempts: usize = 0;
1991
1992 for entry in files {
1993 let rel = std::path::Path::new(&entry.path);
1994 if rel
1995 .components()
1996 .any(|c| matches!(c, std::path::Component::ParentDir))
1997 {
1998 traversal_attempts += 1;
1999 if traversal_attempts >= 5 {
2000 let _ = tokio::fs::remove_dir_all(staging).await;
2001 tracing::warn!(
2002 event = "upload_path_traversal",
2003 upload_id = %upload_id,
2004 "Upload rejected: repeated path traversal attempts detected"
2005 );
2006 return Err((
2007 StatusCode::BAD_REQUEST,
2008 Json(serde_json::json!({"error": "Upload rejected"})),
2009 )
2010 .into_response());
2011 }
2012 continue;
2013 }
2014
2015 if let Err(resp) =
2016 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2017 {
2018 let _ = tokio::fs::remove_dir_all(staging).await;
2019 return Err(resp);
2020 }
2021 }
2022
2023 Ok((files.len(), project_root))
2024}
2025
2026fn parse_tarball_size_caps() -> (u64, u64) {
2029 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2030 .ok()
2031 .and_then(|v| v.parse().ok())
2032 .unwrap_or(2048_u64)
2033 * 1024
2034 * 1024;
2035 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2036 .ok()
2037 .and_then(|v| v.parse().ok())
2038 .unwrap_or(10_240_u64)
2039 * 1024
2040 * 1024;
2041 (compressed, decompressed)
2042}
2043
2044#[allow(clippy::result_large_err)] async fn stream_body_to_file(
2049 body: axum::body::Body,
2050 dest_path: &Path,
2051 max_bytes: u64,
2052) -> Result<u64, Response> {
2053 use http_body_util::BodyExt as _;
2054 use tokio::io::AsyncWriteExt as _;
2055
2056 let mut file = match tokio::fs::File::create(dest_path).await {
2057 Ok(f) => f,
2058 Err(e) => {
2059 tracing::error!(
2060 event = "upload_io_error",
2061 "failed to create tarball temp file: {e}"
2062 );
2063 return Err((
2064 StatusCode::INTERNAL_SERVER_ERROR,
2065 Json(serde_json::json!({"error": "Upload initialization failed"})),
2066 )
2067 .into_response());
2068 }
2069 };
2070
2071 let mut body = body;
2072 let mut written: u64 = 0;
2073 loop {
2074 match body.frame().await {
2075 None => break,
2076 Some(Err(e)) => {
2077 let _ = tokio::fs::remove_file(dest_path).await;
2078 return Err((
2079 StatusCode::BAD_REQUEST,
2080 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2081 )
2082 .into_response());
2083 }
2084 Some(Ok(frame)) => {
2085 if let Ok(data) = frame.into_data() {
2086 written += data.len() as u64;
2087 if written > max_bytes {
2088 let _ = tokio::fs::remove_file(dest_path).await;
2089 return Err((
2090 StatusCode::PAYLOAD_TOO_LARGE,
2091 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2092 )
2093 .into_response());
2094 }
2095 if let Err(e) = file.write_all(&data).await {
2096 let _ = tokio::fs::remove_file(dest_path).await;
2097 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2098 return Err((
2099 StatusCode::INTERNAL_SERVER_ERROR,
2100 Json(serde_json::json!({"error": "Upload write failed"})),
2101 )
2102 .into_response());
2103 }
2104 }
2105 }
2106 }
2107 }
2108 drop(file);
2109 Ok(written)
2110}
2111
2112#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
2117 tarball_path: &Path,
2118 staging: &Path,
2119 max_decompressed_bytes: u64,
2120) -> Result<(), Response> {
2121 let staging_clone = staging.to_path_buf();
2122 let tarball_clone = tarball_path.to_path_buf();
2123 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2124 let file = std::fs::File::open(&tarball_clone)?;
2125 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2126 let limited = SizeLimitReader {
2127 inner: gz,
2128 remaining: max_decompressed_bytes,
2129 };
2130 let mut archive = tar::Archive::new(limited);
2131 archive.set_overwrite(true);
2132 archive.set_preserve_permissions(false);
2133 std::fs::create_dir_all(&staging_clone)?;
2134 archive.unpack(&staging_clone)?;
2135 Ok(())
2136 })
2137 .await;
2138 let _ = tokio::fs::remove_file(tarball_path).await;
2139
2140 match extract_result {
2141 Ok(Ok(())) => Ok(()),
2142 Ok(Err(e)) => {
2143 let _ = tokio::fs::remove_dir_all(staging).await;
2144 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2145 tracing::warn!(
2146 event = "upload_extract_error",
2147 "tarball extraction failed: {e:#}"
2148 );
2149 let (status, msg) = if is_size_limit {
2150 (
2151 StatusCode::PAYLOAD_TOO_LARGE,
2152 "Archive exceeds the decompressed size limit",
2153 )
2154 } else {
2155 (StatusCode::BAD_REQUEST, "Failed to extract archive")
2156 };
2157 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2158 }
2159 Err(e) => {
2160 let _ = tokio::fs::remove_dir_all(staging).await;
2161 tracing::error!(
2162 event = "upload_extract_panic",
2163 "tarball extraction task panicked: {e}"
2164 );
2165 Err((
2166 StatusCode::INTERNAL_SERVER_ERROR,
2167 Json(serde_json::json!({"error": "Archive extraction failed"})),
2168 )
2169 .into_response())
2170 }
2171 }
2172}
2173
2174async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2178 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2179 let first = entries.next_entry().await.ok()??;
2180 if !first.path().is_dir() {
2181 return None;
2182 }
2183 if entries.next_entry().await.unwrap_or(None).is_some() {
2184 return None;
2185 }
2186 Some(first.path())
2187}
2188
2189#[derive(Deserialize)]
2196struct UploadDirRequest {
2197 files: Vec<UploadedFile>,
2198 upload_id: Option<String>,
2201}
2202
2203#[derive(Deserialize)]
2204struct UploadedFile {
2205 path: String,
2207 content: String,
2209}
2210
2211async fn upload_directory_handler(
2221 State(state): State<AppState>,
2222 Json(body): Json<UploadDirRequest>,
2223) -> Response {
2224 if !state.server_mode {
2225 return StatusCode::NOT_FOUND.into_response();
2226 }
2227 if let Err(resp) = validate_upload_dir_request(&body) {
2228 return resp;
2229 }
2230 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2233 match write_upload_files(&body.files, &staging, &upload_id).await {
2234 Ok((file_count, project_root)) => {
2235 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2236 Json(serde_json::json!({
2237 "tmp_path": scan_root.to_string_lossy(),
2238 "file_count": file_count,
2239 "upload_id": upload_id.clone()
2240 }))
2241 .into_response()
2242 }
2243 Err(resp) => resp,
2244 }
2245}
2246
2247#[derive(Deserialize)]
2249struct UploadFileRequest {
2250 filename: String,
2252 content: String,
2254}
2255
2256async fn upload_file_handler(
2262 State(state): State<AppState>,
2263 Json(body): Json<UploadFileRequest>,
2264) -> Response {
2265 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2268 return StatusCode::NOT_FOUND.into_response();
2269 }
2270
2271 let Ok(data) = base64::Engine::decode(
2272 &base64::engine::general_purpose::STANDARD,
2273 body.content.as_bytes(),
2274 ) else {
2275 return (
2276 StatusCode::BAD_REQUEST,
2277 Json(serde_json::json!({"error": "Invalid base64 content"})),
2278 )
2279 .into_response();
2280 };
2281
2282 if data.len() > MAX_FILE_BYTES {
2283 return (
2284 StatusCode::PAYLOAD_TOO_LARGE,
2285 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2286 )
2287 .into_response();
2288 }
2289
2290 let filename = std::path::Path::new(&body.filename)
2292 .file_name()
2293 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2294
2295 let upload_id = uuid::Uuid::new_v4();
2296 let staging = std::env::temp_dir()
2297 .join("oxide-sloc-uploads")
2298 .join(upload_id.to_string());
2299
2300 if tokio::fs::create_dir_all(&staging).await.is_err() {
2301 return (
2302 StatusCode::INTERNAL_SERVER_ERROR,
2303 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2304 )
2305 .into_response();
2306 }
2307
2308 let dest = staging.join(&filename);
2309 if tokio::fs::write(&dest, &data).await.is_err() {
2310 let _ = tokio::fs::remove_dir_all(&staging).await;
2311 return (
2312 StatusCode::INTERNAL_SERVER_ERROR,
2313 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2314 )
2315 .into_response();
2316 }
2317
2318 Json(serde_json::json!({
2319 "tmp_path": dest.to_string_lossy(),
2320 "upload_id": upload_id.to_string()
2321 }))
2322 .into_response()
2323}
2324
2325struct SizeLimitReader<R> {
2340 inner: R,
2341 remaining: u64,
2342}
2343impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2344 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2345 if self.remaining == 0 {
2346 return Err(std::io::Error::other("decompressed size limit exceeded"));
2347 }
2348 let n = self.inner.read(buf)?;
2349 self.remaining = self.remaining.saturating_sub(n as u64);
2350 Ok(n)
2351 }
2352}
2353
2354async fn upload_tarball_handler(
2355 State(state): State<AppState>,
2356 request: axum::extract::Request,
2357) -> Response {
2358 if !state.server_mode {
2359 return StatusCode::NOT_FOUND.into_response();
2360 }
2361
2362 let upload_id = uuid::Uuid::new_v4().to_string();
2363 let upload_base = upload_base_dir();
2364 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2365 let staging = upload_staging_path(&upload_id);
2366 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2367
2368 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2369 tracing::error!(
2370 event = "upload_io_error",
2371 "failed to create upload base dir: {e}"
2372 );
2373 return (
2374 StatusCode::INTERNAL_SERVER_ERROR,
2375 Json(serde_json::json!({"error": "Upload initialization failed"})),
2376 )
2377 .into_response();
2378 }
2379
2380 let compressed_bytes =
2382 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2383 Ok(n) => n,
2384 Err(resp) => return resp,
2385 };
2386
2387 if let Err(resp) =
2389 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2390 {
2391 return resp;
2392 }
2393
2394 let scan_root = find_single_top_dir(&staging)
2399 .await
2400 .unwrap_or_else(|| staging.clone());
2401
2402 let original_bytes = tokio::task::spawn_blocking({
2404 let p = scan_root.clone();
2405 move || dir_size_bytes(&p)
2406 })
2407 .await
2408 .unwrap_or(0);
2409
2410 Json(serde_json::json!({
2411 "tmp_path": scan_root.to_string_lossy(),
2412 "upload_id": upload_id,
2413 "compressed_bytes": compressed_bytes,
2414 "original_bytes": original_bytes,
2415 }))
2416 .into_response()
2417}
2418
2419#[derive(Deserialize)]
2420struct LocateReportForm {
2421 file_path: String,
2422 #[serde(default)]
2423 redirect_url: Option<String>,
2424 #[serde(default)]
2425 expected_run_id: Option<String>,
2426}
2427
2428fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2430 let html = ErrorTemplate {
2431 message: message.into(),
2432 last_report_url: Some("/view-reports".to_string()),
2433 last_report_label: Some("View Reports".to_string()),
2434 run_id: None,
2435 error_code: None,
2436 csp_nonce: csp_nonce.to_owned(),
2437 version: env!("CARGO_PKG_VERSION"),
2438 }
2439 .render()
2440 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2441 Html(html).into_response()
2442}
2443
2444fn registry_entry_from_run(
2446 run: &AnalysisRun,
2447 json_path: PathBuf,
2448 html_path: PathBuf,
2449) -> RegistryEntry {
2450 let project_label = run.input_roots.first().map_or_else(
2451 || "Unknown Project".to_string(),
2452 |r| sanitize_project_label(r),
2453 );
2454 RegistryEntry {
2455 run_id: run.tool.run_id.clone(),
2456 timestamp_utc: run.tool.timestamp_utc,
2457 project_label,
2458 input_roots: run.input_roots.clone(),
2459 json_path: Some(json_path),
2460 html_path: Some(html_path),
2461 pdf_path: None,
2462 summary: ScanSummarySnapshot {
2463 files_analyzed: run.summary_totals.files_analyzed,
2464 files_skipped: run.summary_totals.files_skipped,
2465 total_physical_lines: run.summary_totals.total_physical_lines,
2466 code_lines: run.summary_totals.code_lines,
2467 comment_lines: run.summary_totals.comment_lines,
2468 blank_lines: run.summary_totals.blank_lines,
2469 functions: run.summary_totals.functions,
2470 classes: run.summary_totals.classes,
2471 variables: run.summary_totals.variables,
2472 imports: run.summary_totals.imports,
2473 test_count: run.summary_totals.test_count,
2474 coverage_lines_found: run.summary_totals.coverage_lines_found,
2475 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2476 coverage_functions_found: run.summary_totals.coverage_functions_found,
2477 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2478 coverage_branches_found: run.summary_totals.coverage_branches_found,
2479 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2480 },
2481 csv_path: None,
2482 xlsx_path: None,
2483 git_branch: None,
2484 git_commit: None,
2485 git_author: None,
2486 git_tags: None,
2487 git_nearest_tag: None,
2488 git_commit_date: None,
2489 }
2490}
2491
2492pub(crate) async fn register_artifacts_in_registry(
2495 state: &AppState,
2496 label: &str,
2497 run: &AnalysisRun,
2498 artifacts: &RunArtifacts,
2499) {
2500 let Some(json_path) = artifacts.json_path.clone() else {
2501 return;
2502 };
2503 let Some(html_path) = artifacts.html_path.clone() else {
2504 return;
2505 };
2506 let mut entry = registry_entry_from_run(run, json_path, html_path);
2507 entry.project_label = label.to_owned();
2508 let mut reg = state.registry.lock().await;
2509 reg.add_entry(entry);
2510 let _ = reg.save(&state.registry_path);
2511}
2512
2513fn is_html_report_file(p: &Path) -> bool {
2514 p.is_file()
2515 && p.extension()
2516 .and_then(|x| x.to_str())
2517 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
2518 && p.file_name()
2519 .and_then(|n| n.to_str())
2520 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
2521}
2522
2523fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
2524 fs::read_dir(dir)
2525 .ok()?
2526 .flatten()
2527 .map(|e| e.path())
2528 .find(|p| is_html_report_file(p))
2529}
2530
2531fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
2532 if let Some(f) = find_html_report_in_dir(dir) {
2533 return Some(f);
2534 }
2535 if let Ok(rd) = fs::read_dir(dir) {
2536 for entry in rd.flatten() {
2537 let sub = entry.path();
2538 if sub.is_dir() {
2539 if let Some(f) = find_html_report_in_dir(&sub) {
2540 return Some(f);
2541 }
2542 }
2543 }
2544 }
2545 None
2546}
2547
2548#[allow(clippy::result_large_err)]
2553fn validate_locate_request(
2554 state: &AppState,
2555 file_path: &str,
2556 csp_nonce: &str,
2557) -> Result<(PathBuf, PathBuf), Response> {
2558 let raw = PathBuf::from(file_path);
2559
2560 let html_path = if raw.is_dir() {
2562 let found = find_html_report_in_tree(&raw);
2563 match found {
2564 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
2565 None => {
2566 return Err(locate_report_error(
2567 "No HTML report file found in the selected folder.\n\nMake sure you selected \
2568 the folder that contains your scan output (result_*.html or report_*.html).",
2569 csp_nonce,
2570 ));
2571 }
2572 }
2573 } else {
2574 let file_ext = raw
2575 .extension()
2576 .and_then(|e| e.to_str())
2577 .unwrap_or("")
2578 .to_ascii_lowercase();
2579 if file_ext != "html" {
2580 return Err(locate_report_error(
2581 "Please select the scan output folder, or an .html report file directly.",
2582 csp_nonce,
2583 ));
2584 }
2585 match fs::canonicalize(&raw) {
2586 Ok(p) => strip_unc_prefix(p),
2587 Err(_) => {
2588 return Err(locate_report_error(
2589 "Report file not found or path is invalid.",
2590 csp_nonce,
2591 ));
2592 }
2593 }
2594 };
2595
2596 if state.server_mode {
2597 let output_root = resolve_output_root(None);
2598 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2599 if !html_path.starts_with(&canonical_root) {
2600 return Err(locate_report_error(
2601 "Report file must be within the configured output directory.",
2602 csp_nonce,
2603 ));
2604 }
2605 }
2606 let parent = match html_path.parent() {
2607 Some(p) => p.to_path_buf(),
2608 None => {
2609 return Err(locate_report_error(
2610 "Report file has no parent directory.",
2611 csp_nonce,
2612 ));
2613 }
2614 };
2615 Ok((html_path, parent))
2616}
2617
2618fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
2620 if want_json {
2621 (
2622 StatusCode::UNPROCESSABLE_ENTITY,
2623 axum::Json(serde_json::json!({"ok": false, "message": msg})),
2624 )
2625 .into_response()
2626 } else {
2627 locate_report_error(msg, csp_nonce)
2628 }
2629}
2630
2631fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
2633 if want_json {
2634 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
2635 } else {
2636 axum::response::Redirect::to(redirect).into_response()
2637 }
2638}
2639
2640fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
2643 for jpath in candidates {
2644 if let Ok(run) = read_json(jpath) {
2645 if expected.is_empty() || run.tool.run_id == expected {
2646 return Some((jpath.clone(), run.tool.run_id));
2647 }
2648 }
2649 }
2650 None
2651}
2652
2653fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
2654 html_path
2655 .parent()
2656 .and_then(|p| p.parent())
2657 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
2658}
2659
2660fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2661 let mut hits = collect_result_json_candidates(scan_root);
2662 if hits.is_empty() {
2663 hits = collect_result_json_candidates(parent);
2664 }
2665 hits.sort();
2666 hits
2667}
2668
2669#[allow(clippy::too_many_lines)]
2670async fn locate_report_handler(
2671 State(state): State<AppState>,
2672 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2673 headers: axum::http::HeaderMap,
2674 Form(form): Form<LocateReportForm>,
2675) -> impl IntoResponse {
2676 let want_json = headers
2677 .get(axum::http::header::ACCEPT)
2678 .and_then(|v| v.to_str().ok())
2679 .is_some_and(|v| v.contains("application/json"));
2680
2681 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2682 Ok(v) => v,
2683 Err(resp) => {
2684 if want_json {
2685 return locate_handler_err(
2686 true,
2687 "No HTML report file found in the selected folder. \
2688 Make sure you selected the folder that contains your \
2689 scan output (look for the folder with html/, json/, pdf/ subdirs)."
2690 .to_string(),
2691 &csp_nonce,
2692 );
2693 }
2694 return resp;
2695 }
2696 };
2697
2698 let scan_root_owned = resolve_scan_root(&html_path, &parent);
2701 let scan_root: &Path = &scan_root_owned;
2702 let json_candidates = gather_json_candidates(scan_root, &parent);
2703
2704 let expected_run_id = form
2706 .expected_run_id
2707 .as_deref()
2708 .unwrap_or("")
2709 .trim()
2710 .to_string();
2711
2712 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
2713
2714 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
2716 let actual = json_candidates
2717 .iter()
2718 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
2719 .unwrap_or_else(|| "unknown".to_string());
2720 return locate_handler_err(
2721 want_json,
2722 format!(
2723 "This folder contains a different scan.\n\n\
2724 Expected run ID : {expected_run_id}\n\
2725 Found run ID : {actual}\n\n\
2726 Please select the folder that contains the correct scan output."
2727 ),
2728 &csp_nonce,
2729 );
2730 }
2731
2732 let safe_redirect = form
2733 .redirect_url
2734 .as_deref()
2735 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
2736 .unwrap_or("/view-reports?linked=1")
2737 .to_string();
2738
2739 let mut reg = state.registry.lock().await;
2740
2741 if let Some((json_path, run_id)) = matched_json {
2742 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2744 entry.html_path = Some(html_path);
2745 entry.json_path = Some(json_path);
2746 let _ = reg.save(&state.registry_path);
2747 drop(reg);
2748 state.artifacts.lock().await.remove(&run_id);
2750 return redirect_or_json_ok(want_json, &safe_redirect);
2751 }
2752 match read_json(&json_path) {
2754 Ok(run) => {
2755 let entry = registry_entry_from_run(&run, json_path, html_path);
2756 reg.add_entry(entry);
2757 let _ = reg.save(&state.registry_path);
2758 drop(reg);
2759 state.artifacts.lock().await.remove(&run_id);
2760 return redirect_or_json_ok(want_json, &safe_redirect);
2761 }
2762 Err(e) => {
2763 drop(reg);
2764 return locate_handler_err(
2765 want_json,
2766 format!(
2767 "Found the scan folder but could not parse the result JSON.\n\n\
2768 The file may have been saved by an older version of OxideSLOC. \
2769 Re-running the analysis will create a fresh, compatible record.\n\n\
2770 Error: {e}"
2771 ),
2772 &csp_nonce,
2773 );
2774 }
2775 }
2776 }
2777
2778 if let Some(entry) = reg
2780 .entries
2781 .iter_mut()
2782 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
2783 {
2784 entry.html_path = Some(html_path.clone());
2785 let _ = reg.save(&state.registry_path);
2786 drop(reg);
2787 state.artifacts.lock().await.remove(&expected_run_id);
2788 return redirect_or_json_ok(want_json, &safe_redirect);
2789 }
2790
2791 drop(reg);
2792 let hint = if state.server_mode {
2793 String::new()
2794 } else {
2795 format!(
2796 "\n\nSearched folder : {}\nHTML found : {}",
2797 scan_root.display(),
2798 html_path.display()
2799 )
2800 };
2801 locate_handler_err(
2802 want_json,
2803 format!(
2804 "Could not link this report.\n\n\
2805 No result_*.json was found in the selected folder. \
2806 Make sure you selected the top-level scan output folder \
2807 (the one that contains html/, json/, pdf/ subfolders).{hint}"
2808 ),
2809 &csp_nonce,
2810 )
2811}
2812
2813fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2815 fs::read_dir(dir)
2816 .ok()?
2817 .flatten()
2818 .map(|e| e.path())
2819 .find(|p| {
2820 p.is_file()
2821 && p.file_stem()
2822 .and_then(|n| n.to_str())
2823 .is_some_and(|n| n.starts_with("result"))
2824 && p.extension()
2825 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2826 })
2827}
2828
2829#[derive(Deserialize)]
2830struct LocateReportsDirForm {
2831 folder_path: String,
2832}
2833
2834#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
2836 State(state): State<AppState>,
2837 Form(form): Form<LocateReportsDirForm>,
2838) -> impl IntoResponse {
2839 if state.server_mode {
2840 return StatusCode::NOT_FOUND.into_response();
2841 }
2842 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2843 Ok(p) => strip_unc_prefix(p),
2844 Err(_) => {
2845 return axum::response::Redirect::to(
2846 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2847 )
2848 .into_response();
2849 }
2850 };
2851 if !folder.is_dir() {
2852 return axum::response::Redirect::to(
2853 "/view-reports?error=Selected+path+is+not+a+directory.",
2854 )
2855 .into_response();
2856 }
2857
2858 let candidates = collect_result_json_candidates(&folder);
2859
2860 if candidates.is_empty() {
2861 return axum::response::Redirect::to(
2862 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2863 )
2864 .into_response();
2865 }
2866
2867 let mut linked_count: usize = 0;
2868 let mut reg = state.registry.lock().await;
2869 for json_path in candidates {
2870 let Some(parent) = json_path.parent().map(PathBuf::from) else {
2871 continue;
2872 };
2873 if is_dir_already_registered(®, &parent) {
2874 continue;
2875 }
2876 let Some(entry) = build_registry_entry_from_json(json_path) else {
2877 continue;
2878 };
2879 reg.add_entry(entry);
2880 linked_count += 1;
2881 }
2882 let _ = reg.save(&state.registry_path);
2883 drop(reg);
2884
2885 if linked_count == 0 {
2886 return axum::response::Redirect::to(
2887 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2888 )
2889 .into_response();
2890 }
2891 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2892}
2893
2894#[derive(Deserialize)]
2895struct RelocateScanForm {
2896 run_id: String,
2897 folder_path: String,
2898 redirect_url: String,
2899}
2900
2901fn relocate_folder_err(
2904 want_json: bool,
2905 status: StatusCode,
2906 msg: &str,
2907 run_id: &str,
2908 folder_hint: &str,
2909 redirect_url: &str,
2910 csp_nonce: &str,
2911) -> Response {
2912 if want_json {
2913 (
2914 status,
2915 axum::Json(serde_json::json!({"ok": false, "message": msg})),
2916 )
2917 .into_response()
2918 } else {
2919 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
2920 }
2921}
2922
2923#[allow(clippy::too_many_lines)]
2924async fn relocate_scan_handler(
2925 State(state): State<AppState>,
2926 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2927 headers: axum::http::HeaderMap,
2928 Form(form): Form<RelocateScanForm>,
2929) -> impl IntoResponse {
2930 let want_json = headers
2931 .get(axum::http::header::ACCEPT)
2932 .and_then(|v| v.to_str().ok())
2933 .is_some_and(|v| v.contains("application/json"));
2934 if state.server_mode {
2935 return StatusCode::NOT_FOUND.into_response();
2936 }
2937
2938 let run_id = form.run_id.trim().to_string();
2939 let redirect_url = form.redirect_url.trim().to_string();
2940
2941 let run_exists = {
2942 let reg = state.registry.lock().await;
2943 reg.find_by_run_id(&run_id).is_some()
2944 };
2945 if !run_exists {
2946 if want_json {
2947 return (
2948 StatusCode::NOT_FOUND,
2949 axum::Json(serde_json::json!({
2950 "ok": false,
2951 "message": format!("Run ID '{run_id}' not found in registry.")
2952 })),
2953 )
2954 .into_response();
2955 }
2956 let html = ErrorTemplate {
2957 message: format!("Run ID '{run_id}' not found in registry."),
2958 last_report_url: Some("/compare-scans".to_string()),
2959 last_report_label: Some("Compare Scans".to_string()),
2960 run_id: Some(run_id.clone()),
2961 error_code: Some(404),
2962 csp_nonce: csp_nonce.clone(),
2963 version: env!("CARGO_PKG_VERSION"),
2964 }
2965 .render()
2966 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2967 return Html(html).into_response();
2968 }
2969
2970 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2971 Ok(p) => strip_unc_prefix(p),
2972 Err(_) => {
2973 return relocate_folder_err(
2974 want_json,
2975 StatusCode::UNPROCESSABLE_ENTITY,
2976 "Folder not found or path is invalid.",
2977 &run_id,
2978 form.folder_path.trim(),
2979 &redirect_url,
2980 &csp_nonce,
2981 );
2982 }
2983 };
2984 if !folder.is_dir() {
2985 return relocate_folder_err(
2986 want_json,
2987 StatusCode::UNPROCESSABLE_ENTITY,
2988 "Selected path is not a directory.",
2989 &run_id,
2990 &folder.display().to_string(),
2991 &redirect_url,
2992 &csp_nonce,
2993 );
2994 }
2995
2996 let json_candidates = find_result_files_by_ext(&folder, "json");
2997 if json_candidates.is_empty() {
2998 let msg = format!(
2999 "No result JSON files found in the selected folder.\nSearched: {}",
3000 folder.display()
3001 );
3002 return relocate_folder_err(
3003 want_json,
3004 StatusCode::UNPROCESSABLE_ENTITY,
3005 &msg,
3006 &run_id,
3007 &folder.display().to_string(),
3008 &redirect_url,
3009 &csp_nonce,
3010 );
3011 }
3012
3013 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3014 let msg = format!(
3015 "No matching scan found in the selected folder.\n\
3016 The JSON files present do not contain run ID: {run_id}\n\
3017 Searched: {}",
3018 folder.display()
3019 );
3020 return relocate_folder_err(
3021 want_json,
3022 StatusCode::UNPROCESSABLE_ENTITY,
3023 &msg,
3024 &run_id,
3025 &folder.display().to_string(),
3026 &redirect_url,
3027 &csp_nonce,
3028 );
3029 };
3030
3031 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3032 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3033 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3034
3035 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3036 redirect_url
3037 } else {
3038 "/compare-scans".to_string()
3039 };
3040 redirect_or_json_ok(want_json, &safe_redirect)
3041}
3042
3043fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3044 let mut out = Vec::new();
3045 collect_scan_files_by_ext(folder, ext, &mut out);
3046 if let Ok(rd) = fs::read_dir(folder) {
3047 for entry in rd.flatten() {
3048 let sub = entry.path();
3049 if sub.is_dir() {
3050 collect_scan_files_by_ext(&sub, ext, &mut out);
3051 }
3052 }
3053 }
3054 out
3055}
3056
3057fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3058 let Ok(rd) = fs::read_dir(dir) else { return };
3059 for entry in rd.flatten() {
3060 let p = entry.path();
3061 if p.is_file()
3062 && p.file_stem()
3063 .and_then(|n| n.to_str())
3064 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3065 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3066 {
3067 out.push(p);
3068 }
3069 }
3070}
3071
3072fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3073 candidates
3074 .iter()
3075 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3076 .cloned()
3077}
3078
3079async fn update_run_file_paths(
3080 state: &AppState,
3081 run_id: &str,
3082 json_path: PathBuf,
3083 html_path: Option<PathBuf>,
3084 pdf_path: Option<PathBuf>,
3085) {
3086 let mut reg = state.registry.lock().await;
3087 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3088 entry.json_path = Some(json_path);
3089 if let Some(hp) = html_path {
3090 entry.html_path = Some(hp);
3091 }
3092 if let Some(pp) = pdf_path {
3093 entry.pdf_path = Some(pp);
3094 }
3095 }
3096 let _ = reg.save(&state.registry_path);
3097}
3098
3099fn missing_scan_relocate_response(
3100 message: &str,
3101 run_id: &str,
3102 folder_hint: &str,
3103 redirect_url: &str,
3104 server_mode: bool,
3105 csp_nonce: &str,
3106) -> axum::response::Response {
3107 let html = RelocateScanTemplate {
3108 message: message.to_string(),
3109 run_id: run_id.to_string(),
3110 folder_hint: folder_hint.to_string(),
3111 redirect_url: redirect_url.to_string(),
3112 server_mode,
3113 csp_nonce: csp_nonce.to_owned(),
3114 version: env!("CARGO_PKG_VERSION"),
3115 }
3116 .render()
3117 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3118 (StatusCode::NOT_FOUND, Html(html)).into_response()
3119}
3120
3121fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3125 let mut candidates = Vec::new();
3126 if let Some(j) = find_result_json_in_dir(folder) {
3127 candidates.push(j);
3128 }
3129 if let Ok(dir_entries) = fs::read_dir(folder) {
3130 for entry in dir_entries.flatten() {
3131 let sub = entry.path();
3132 if sub.is_dir() {
3133 if let Some(j) = find_result_json_in_dir(&sub) {
3134 candidates.push(j);
3135 }
3136 }
3137 }
3138 }
3139 candidates
3140}
3141
3142fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3143 reg.entries.iter().any(|e| {
3144 let dir_match = e
3145 .json_path
3146 .as_ref()
3147 .and_then(|p| p.parent())
3148 .is_some_and(|p| p == parent)
3149 || e.html_path
3150 .as_ref()
3151 .and_then(|p| p.parent())
3152 .is_some_and(|p| p == parent);
3153 dir_match
3154 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3155 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3156 })
3157}
3158
3159fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3160 let parent = json_path.parent()?.to_path_buf();
3161 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3162 rd.flatten()
3163 .map(|e| e.path())
3164 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3165 });
3166 let run = read_json(&json_path).ok()?;
3167 let project_label = run.input_roots.first().map_or_else(
3168 || "Unknown Project".to_string(),
3169 |r| sanitize_project_label(r),
3170 );
3171 Some(RegistryEntry {
3172 run_id: run.tool.run_id.clone(),
3173 timestamp_utc: run.tool.timestamp_utc,
3174 project_label,
3175 input_roots: run.input_roots.clone(),
3176 json_path: Some(json_path),
3177 html_path,
3178 pdf_path: None,
3179 csv_path: None,
3180 xlsx_path: None,
3181 summary: ScanSummarySnapshot {
3182 files_analyzed: run.summary_totals.files_analyzed,
3183 files_skipped: run.summary_totals.files_skipped,
3184 total_physical_lines: run.summary_totals.total_physical_lines,
3185 code_lines: run.summary_totals.code_lines,
3186 comment_lines: run.summary_totals.comment_lines,
3187 blank_lines: run.summary_totals.blank_lines,
3188 functions: run.summary_totals.functions,
3189 classes: run.summary_totals.classes,
3190 variables: run.summary_totals.variables,
3191 imports: run.summary_totals.imports,
3192 test_count: run.summary_totals.test_count,
3193 coverage_lines_found: run.summary_totals.coverage_lines_found,
3194 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3195 coverage_functions_found: run.summary_totals.coverage_functions_found,
3196 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3197 coverage_branches_found: run.summary_totals.coverage_branches_found,
3198 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3199 },
3200 git_branch: run.git_branch.clone(),
3201 git_commit: run.git_commit_short.clone(),
3202 git_author: run.git_commit_author.clone(),
3203 git_tags: run.git_tags.clone(),
3204 git_nearest_tag: run.git_nearest_tag.clone(),
3205 git_commit_date: run.git_commit_date,
3206 })
3207}
3208
3209fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3212 let mut linked = 0usize;
3213 for json_path in collect_result_json_candidates(folder) {
3214 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3215 continue;
3216 };
3217 if is_dir_already_registered(reg, &parent) {
3218 continue;
3219 }
3220 let Some(entry) = build_registry_entry_from_json(json_path) else {
3221 continue;
3222 };
3223 reg.add_entry(entry);
3224 linked += 1;
3225 }
3226 linked
3227}
3228
3229async fn auto_scan_watched_dirs(state: &AppState) {
3231 let dirs: Vec<PathBuf> = {
3232 let wd = state.watched_dirs.lock().await;
3233 wd.dirs.clone()
3234 };
3235 if dirs.is_empty() {
3236 return;
3237 }
3238 let mut reg = state.registry.lock().await;
3239 let mut total = 0usize;
3240 for dir in &dirs {
3241 if dir.is_dir() {
3242 total += scan_folder_into_registry(dir, &mut reg);
3243 }
3244 }
3245 if total > 0 {
3246 let _ = reg.save(&state.registry_path);
3247 }
3248}
3249
3250#[derive(Deserialize)]
3253struct WatchedDirForm {
3254 folder_path: String,
3255 #[serde(default = "default_redirect")]
3256 redirect_to: String,
3257}
3258
3259fn default_redirect() -> String {
3260 "/view-reports".to_string()
3261}
3262
3263#[derive(Deserialize)]
3264struct WatchedDirRefreshForm {
3265 #[serde(default = "default_redirect")]
3266 redirect_to: String,
3267}
3268
3269fn safe_redirect(dest: &str) -> &str {
3273 if dest.starts_with('/') {
3274 dest
3275 } else {
3276 "/"
3277 }
3278}
3279
3280async fn add_watched_dir_handler(
3283 State(state): State<AppState>,
3284 Form(form): Form<WatchedDirForm>,
3285) -> impl IntoResponse {
3286 if state.server_mode {
3287 return StatusCode::NOT_FOUND.into_response();
3288 }
3289 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3290 strip_unc_prefix(p)
3291 } else {
3292 let dest = format!(
3293 "{}?error=Folder+not+found+or+path+is+invalid.",
3294 safe_redirect(&form.redirect_to)
3295 );
3296 return axum::response::Redirect::to(&dest).into_response();
3297 };
3298 if !folder.is_dir() {
3299 let dest = format!(
3300 "{}?error=Selected+path+is+not+a+directory.",
3301 safe_redirect(&form.redirect_to)
3302 );
3303 return axum::response::Redirect::to(&dest).into_response();
3304 }
3305
3306 {
3308 let mut wd = state.watched_dirs.lock().await;
3309 wd.add(folder.clone());
3310 let _ = wd.save(&state.watched_dirs_path);
3311 }
3312
3313 let linked = {
3315 let mut reg = state.registry.lock().await;
3316 let n = scan_folder_into_registry(&folder, &mut reg);
3317 if n > 0 {
3318 let _ = reg.save(&state.registry_path);
3319 }
3320 n
3321 };
3322
3323 let dest = if linked > 0 {
3324 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3325 } else {
3326 format!(
3327 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3328 safe_redirect(&form.redirect_to)
3329 )
3330 };
3331 axum::response::Redirect::to(&dest).into_response()
3332}
3333
3334async fn remove_watched_dir_handler(
3335 State(state): State<AppState>,
3336 Form(form): Form<WatchedDirForm>,
3337) -> impl IntoResponse {
3338 if state.server_mode {
3339 return StatusCode::NOT_FOUND.into_response();
3340 }
3341 let folder = PathBuf::from(&form.folder_path);
3342 {
3343 let mut wd = state.watched_dirs.lock().await;
3344 wd.remove(&folder);
3345 let _ = wd.save(&state.watched_dirs_path);
3346 }
3347 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3348}
3349
3350async fn refresh_watched_dirs_handler(
3351 State(state): State<AppState>,
3352 Form(form): Form<WatchedDirRefreshForm>,
3353) -> impl IntoResponse {
3354 if state.server_mode {
3355 return StatusCode::NOT_FOUND.into_response();
3356 }
3357 let dirs: Vec<PathBuf> = {
3358 let wd = state.watched_dirs.lock().await;
3359 wd.dirs.clone()
3360 };
3361 let mut total = 0usize;
3362 {
3363 let mut reg = state.registry.lock().await;
3364 for dir in &dirs {
3365 if dir.is_dir() {
3366 total += scan_folder_into_registry(dir, &mut reg);
3367 }
3368 }
3369 if total > 0 {
3370 let _ = reg.save(&state.registry_path);
3371 }
3372 }
3373 let dest = if total > 0 {
3374 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3375 } else {
3376 safe_redirect(&form.redirect_to).to_owned()
3377 };
3378 axum::response::Redirect::to(&dest).into_response()
3379}
3380
3381#[derive(Debug, Deserialize)]
3382struct OpenPathQuery {
3383 path: Option<String>,
3384}
3385
3386fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3387 let mut ancestor = std::path::Path::new(raw);
3388 loop {
3389 match ancestor.parent() {
3390 Some(p) => {
3391 ancestor = p;
3392 if ancestor.is_dir() {
3393 break;
3394 }
3395 }
3396 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3397 }
3398 }
3399 Ok(ancestor.to_path_buf())
3400}
3401
3402async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3403 match tokio::fs::canonicalize(raw).await {
3404 Ok(canonical) if canonical.is_file() => canonical
3405 .parent()
3406 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
3407 Ok(p.to_path_buf())
3408 }),
3409 Ok(canonical) if canonical.is_dir() => Ok(canonical),
3410 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3411 Err(_) => find_existing_ancestor(raw),
3412 }
3413}
3414
3415async fn open_path_handler(
3416 State(state): State<AppState>,
3417 Query(query): Query<OpenPathQuery>,
3418) -> impl IntoResponse {
3419 if state.server_mode {
3420 return Json(serde_json::json!({
3421 "server_mode_disabled": true,
3422 "message": "Opening a path in the file manager is only available in local desktop mode."
3423 }))
3424 .into_response();
3425 }
3426 if std::env::var("SLOC_HEADLESS").is_ok() {
3428 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3429 }
3430 let raw = match query.path.as_deref() {
3431 Some(p) if !p.is_empty() => p,
3432 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3433 };
3434
3435 let target = match resolve_open_target(raw).await {
3439 Ok(p) => p,
3440 Err((code, msg)) => return (code, msg).into_response(),
3441 };
3442
3443 #[cfg(target_os = "windows")]
3444 win_dialog_focus::open_folder_foreground(target);
3445 #[cfg(target_os = "macos")]
3446 let _ = std::process::Command::new("open")
3447 .arg(&target)
3448 .stdout(Stdio::null())
3449 .stderr(Stdio::null())
3450 .spawn();
3451 #[cfg(target_os = "linux")]
3452 {
3453 let folder_name = target
3454 .file_name()
3455 .and_then(|n| n.to_str())
3456 .map(str::to_owned);
3457 let _ = std::process::Command::new("xdg-open")
3458 .arg(&target)
3459 .stdout(Stdio::null())
3460 .stderr(Stdio::null())
3461 .spawn();
3462 if let Some(name) = folder_name {
3466 std::thread::spawn(move || {
3467 std::thread::sleep(std::time::Duration::from_millis(800));
3468 let _ = std::process::Command::new("wmctrl")
3469 .args(["-a", &name])
3470 .stdout(Stdio::null())
3471 .stderr(Stdio::null())
3472 .spawn();
3473 });
3474 }
3475 }
3476
3477 Json(serde_json::json!({"ok": true})).into_response()
3478}
3479
3480async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3481 let (content_type, bytes): (&'static str, &'static [u8]) =
3482 match (folder.as_str(), file.as_str()) {
3483 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3484 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3485 ("icons", "c.png") => ("image/png", IMG_ICON_C),
3486 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3487 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3488 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3489 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3490 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3491 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3492 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3493 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3494 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3495 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3496 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3497 ("icons", "r.png") => ("image/png", IMG_ICON_R),
3498 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3499 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3500 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3501 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3502 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3503 _ => return StatusCode::NOT_FOUND.into_response(),
3504 };
3505 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3506}
3507
3508async fn preview_handler(
3509 State(state): State<AppState>,
3510 Query(query): Query<PreviewQuery>,
3511) -> impl IntoResponse {
3512 let raw_path = query
3513 .path
3514 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3515 let resolved = resolve_input_path(&raw_path);
3516
3517 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3521 return Html(
3522 r#"<div class="preview-error">Sample directory not available on this server.
3523 Enter a path to a project directory or upload files using Browse.</div>"#
3524 .to_string(),
3525 );
3526 }
3527
3528 if state.server_mode {
3529 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3530 if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3532 let config = &state.base_config;
3533 if config.discovery.allowed_scan_roots.is_empty() {
3534 return Html(
3535 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3536 );
3537 }
3538 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3539 fs::canonicalize(root)
3540 .ok()
3541 .is_some_and(|r| canonical.starts_with(&r))
3542 });
3543 if !allowed {
3544 return Html(
3545 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3546 );
3547 }
3548 }
3549 }
3550
3551 let include_patterns = split_patterns(query.include_globs.as_deref());
3552 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3553
3554 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3555 Ok(html) => Html(html),
3556 Err(err) => Html(format!(
3557 r#"<div class="preview-error">Preview failed: {}</div>"#,
3558 escape_html(&err.to_string())
3559 )),
3560 }
3561}
3562
3563#[derive(Debug, Deserialize, Default)]
3564struct SuggestCoverageQuery {
3565 path: Option<String>,
3566}
3567
3568#[derive(Serialize)]
3569struct SuggestCoverageResponse {
3570 found: Option<String>,
3571 tool: Option<&'static str>,
3572 hint: Option<&'static str>,
3573}
3574
3575async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3576 const CANDIDATES: &[&str] = &[
3577 "coverage/lcov.info",
3579 "lcov.info",
3580 "target/llvm-cov/lcov.info",
3581 "target/coverage/lcov.info",
3582 "target/debug/coverage/lcov.info",
3583 "coverage/coverage.lcov",
3584 "build/coverage/lcov.info",
3585 "reports/lcov.info",
3586 "coverage.xml",
3588 "coverage/coverage.xml",
3589 "target/site/cobertura/coverage.xml",
3590 "build/reports/coverage/coverage.xml",
3591 "target/site/jacoco/jacoco.xml",
3593 "build/reports/jacoco/test/jacocoTestReport.xml",
3594 "build/reports/jacoco/jacocoTestReport.xml",
3595 "build/jacoco/jacoco.xml",
3596 ];
3597 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3598 let found = CANDIDATES
3599 .iter()
3600 .map(|rel| root.join(rel))
3601 .find(|p| p.is_file())
3602 .map(|p| display_path(&p));
3603
3604 let (tool, hint) = detect_coverage_tool(&root);
3605 Json(SuggestCoverageResponse { found, tool, hint })
3606}
3607
3608fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3611 if root.join("Cargo.toml").is_file() {
3612 return (
3613 Some("cargo-llvm-cov"),
3614 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3615 );
3616 }
3617 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3618 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3619 }
3620 if root.join("pom.xml").is_file() {
3621 return (Some("jacoco"), Some("mvn test jacoco:report"));
3622 }
3623 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3624 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3625 }
3626 (None, None)
3627}
3628
3629#[allow(clippy::result_large_err)]
3631fn validate_server_scan_path(
3632 config: &sloc_config::AppConfig,
3633 resolved_path: &Path,
3634 csp_nonce: &str,
3635) -> Result<(), Response> {
3636 if config.discovery.allowed_scan_roots.is_empty() {
3637 let template = ErrorTemplate {
3638 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3639 Set allowed_scan_roots in the server config to permit scanning."
3640 .to_string(),
3641 last_report_url: None,
3642 last_report_label: None,
3643 run_id: None,
3644 error_code: Some(403),
3645 csp_nonce: csp_nonce.to_owned(),
3646 version: env!("CARGO_PKG_VERSION"),
3647 };
3648 return Err((
3649 StatusCode::FORBIDDEN,
3650 Html(
3651 template
3652 .render()
3653 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3654 ),
3655 )
3656 .into_response());
3657 }
3658 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3659 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3660 fs::canonicalize(root)
3661 .ok()
3662 .is_some_and(|r| canonical.starts_with(&r))
3663 });
3664 if !allowed {
3665 tracing::warn!(event = "path_rejected", path = %canonical.display(),
3666 "Scan path not in allowed_scan_roots");
3667 let template = ErrorTemplate {
3668 message: "The requested path is not within an allowed scan directory.".to_string(),
3669 last_report_url: None,
3670 last_report_label: None,
3671 run_id: None,
3672 error_code: Some(403),
3673 csp_nonce: csp_nonce.to_owned(),
3674 version: env!("CARGO_PKG_VERSION"),
3675 };
3676 return Err((
3677 StatusCode::FORBIDDEN,
3678 Html(
3679 template
3680 .render()
3681 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3682 ),
3683 )
3684 .into_response());
3685 }
3686 Ok(())
3687}
3688
3689fn apply_output_dir_exclusions(
3691 config: &mut sloc_config::AppConfig,
3692 project_path: &str,
3693 raw_output_dir: &str,
3694) {
3695 let project_root = resolve_input_path(project_path);
3696 let raw_out = raw_output_dir.trim();
3697 let resolved_out = if raw_out.is_empty() {
3698 project_root.join("sloc")
3699 } else if Path::new(raw_out).is_absolute() {
3700 PathBuf::from(raw_out)
3701 } else {
3702 workspace_root().join(raw_out)
3703 };
3704 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3705 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3706 let dir = first.to_string();
3707 if !config.discovery.excluded_directories.contains(&dir) {
3708 config.discovery.excluded_directories.push(dir);
3709 }
3710 }
3711 }
3712 if !config
3713 .discovery
3714 .excluded_directories
3715 .iter()
3716 .any(|d| d == "sloc")
3717 {
3718 config
3719 .discovery
3720 .excluded_directories
3721 .push("sloc".to_string());
3722 }
3723}
3724
3725const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3727 ScanSummarySnapshot {
3728 files_analyzed: run.summary_totals.files_analyzed,
3729 files_skipped: run.summary_totals.files_skipped,
3730 total_physical_lines: run.summary_totals.total_physical_lines,
3731 code_lines: run.summary_totals.code_lines,
3732 comment_lines: run.summary_totals.comment_lines,
3733 blank_lines: run.summary_totals.blank_lines,
3734 functions: run.summary_totals.functions,
3735 classes: run.summary_totals.classes,
3736 variables: run.summary_totals.variables,
3737 imports: run.summary_totals.imports,
3738 test_count: run.summary_totals.test_count,
3739 coverage_lines_found: run.summary_totals.coverage_lines_found,
3740 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3741 coverage_functions_found: run.summary_totals.coverage_functions_found,
3742 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3743 coverage_branches_found: run.summary_totals.coverage_branches_found,
3744 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3745 }
3746}
3747
3748pub(crate) fn build_run_registry_entry(
3750 run: &AnalysisRun,
3751 run_id: &str,
3752 project_label: &str,
3753 artifacts: &RunArtifacts,
3754) -> RegistryEntry {
3755 RegistryEntry {
3756 run_id: run_id.to_owned(),
3757 timestamp_utc: run.tool.timestamp_utc,
3758 project_label: project_label.to_owned(),
3759 input_roots: run.input_roots.clone(),
3760 json_path: artifacts.json_path.clone(),
3761 html_path: artifacts.html_path.clone(),
3762 pdf_path: artifacts.pdf_path.clone(),
3763 csv_path: artifacts.csv_path.clone(),
3764 xlsx_path: artifacts.xlsx_path.clone(),
3765 summary: summary_snapshot_from_run(run),
3766 git_branch: run.git_branch.clone(),
3767 git_commit: run.git_commit_short.clone(),
3768 git_author: run.git_commit_author.clone(),
3769 git_tags: run.git_tags.clone(),
3770 git_nearest_tag: run.git_nearest_tag.clone(),
3771 git_commit_date: run.git_commit_date.clone(),
3772 }
3773}
3774
3775fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3777 if let Some(policy) = form.mixed_line_policy {
3778 config.analysis.mixed_line_policy = policy;
3779 }
3780 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3781 config.analysis.generated_file_detection =
3782 form.generated_file_detection.as_deref() != Some("disabled");
3783 config.analysis.minified_file_detection =
3784 form.minified_file_detection.as_deref() != Some("disabled");
3785 config.analysis.vendor_directory_detection =
3786 form.vendor_directory_detection.as_deref() != Some("disabled");
3787 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3788 if let Some(binary_behavior) = form.binary_file_behavior {
3789 config.analysis.binary_file_behavior = binary_behavior;
3790 }
3791 apply_report_opts(config, form);
3792 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3793 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3794 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3795 if let Some(policy) = form.continuation_line_policy {
3796 config.analysis.continuation_line_policy = policy;
3797 }
3798 if let Some(policy) = form.blank_in_block_comment_policy {
3799 config.analysis.blank_in_block_comment_policy = policy;
3800 }
3801 config.analysis.count_compiler_directives =
3802 form.count_compiler_directives.as_deref() != Some("disabled");
3803 apply_style_threshold(config, form);
3804 apply_coverage_path(config, form);
3805}
3806
3807fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3808 if let Some(report_title) = form.report_title.as_deref() {
3809 let trimmed = report_title.trim();
3810 if !trimmed.is_empty() {
3811 config.reporting.report_title = trimmed.to_string();
3812 }
3813 }
3814 if let Some(hf) = form.report_header_footer.as_deref() {
3815 let trimmed = hf.trim();
3816 config.reporting.report_header_footer = if trimmed.is_empty() {
3817 None
3818 } else {
3819 Some(trimmed.to_string())
3820 };
3821 }
3822}
3823
3824fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3825 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
3826 if let Ok(t) = threshold_str.parse::<u16>() {
3827 if t == 80 || t == 100 || t == 120 {
3828 config.analysis.style_col_threshold = t;
3829 }
3830 }
3831 }
3832 if let Some(v) = form.style_analysis_enabled.as_deref() {
3833 config.analysis.style_analysis_enabled = v != "disabled";
3834 }
3835 if let Some(v) = form.style_score_threshold.as_deref() {
3836 if let Ok(t) = v.parse::<u8>() {
3837 config.analysis.style_score_threshold = t.min(100);
3838 }
3839 }
3840 if let Some(v) = form.style_lang_scope.as_deref() {
3841 let scope = v.trim();
3842 if scope == "c_family" || scope == "all" {
3843 config.analysis.style_lang_scope = scope.to_string();
3844 }
3845 }
3846}
3847
3848fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3849 if let Some(cov) = &form.coverage_file {
3850 let trimmed = cov.trim();
3851 if !trimmed.is_empty() {
3852 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3853 }
3854 }
3855}
3856
3857fn spawn_pdf_background(
3861 pending_pdf: PendingPdf,
3862 run_id: String,
3863 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3864) {
3865 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3866 tokio::spawn(async move {
3867 let result = tokio::task::spawn_blocking(move || {
3868 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3869 if cleanup_src {
3870 let _ = fs::remove_file(&pdf_src);
3871 }
3872 r
3873 })
3874 .await;
3875 let failed = match result {
3876 Ok(Ok(())) => false,
3877 Ok(Err(err)) => {
3878 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3879 true
3880 }
3881 Err(err) => {
3882 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3883 true
3884 }
3885 };
3886 if failed {
3887 let mut map = artifacts.lock().await;
3888 if let Some(entry) = map.get_mut(&run_id) {
3889 entry.pdf_path = None;
3890 }
3891 }
3892 });
3893 }
3894}
3895
3896fn spawn_native_pdf_background(
3900 json_path: PathBuf,
3901 pdf_dest: PathBuf,
3902 run_id: String,
3903 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3904) {
3905 tokio::spawn(async move {
3906 let result = tokio::task::spawn_blocking(move || {
3907 let run = sloc_core::read_json(&json_path)?;
3908 write_pdf_from_run(&run, &pdf_dest)
3909 })
3910 .await;
3911 let failed = match result {
3912 Ok(Ok(())) => false,
3913 Ok(Err(err)) => {
3914 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
3915 true
3916 }
3917 Err(err) => {
3918 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
3919 true
3920 }
3921 };
3922 if failed {
3923 let mut map = artifacts.lock().await;
3924 if let Some(entry) = map.get_mut(&run_id) {
3925 entry.pdf_path = None;
3926 }
3927 }
3928 });
3929}
3930
3931fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3933 cmp.file_deltas
3934 .iter()
3935 .map(|f| match f.status {
3936 FileChangeStatus::Added => f.current_code,
3937 FileChangeStatus::Modified => f.code_delta.max(0),
3938 _ => 0,
3939 })
3940 .sum()
3941}
3942
3943fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3945 cmp.file_deltas
3946 .iter()
3947 .map(|f| match f.status {
3948 FileChangeStatus::Removed => f.baseline_code,
3949 FileChangeStatus::Modified => (-f.code_delta).max(0),
3950 _ => 0,
3951 })
3952 .sum()
3953}
3954
3955fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3957 cmp.file_deltas
3958 .iter()
3959 .filter(|f| f.status == FileChangeStatus::Unchanged)
3960 .map(|f| f.current_code)
3961 .sum()
3962}
3963
3964fn build_submodule_row(
3966 s: &sloc_core::SubmoduleSummary,
3967 run: &AnalysisRun,
3968 run_id: &str,
3969 run_dir: &Path,
3970) -> SubmoduleRow {
3971 let safe = sanitize_project_label(&s.name);
3972 let artifact_key = format!("sub_{safe}");
3973 let pdf_artifact_key = format!("sub_{safe}_pdf");
3974 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
3975 let parent_path = run
3976 .input_roots
3977 .first()
3978 .map_or("", std::string::String::as_str);
3979 let sub_run = build_sub_run(run, s, parent_path);
3980 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
3981 render_sub_report_html(&sub_run, Some(&pdf_server_url))
3982 .ok()
3983 .and_then(|sub_html| {
3984 let sub_dir = run_dir.join("submodules");
3985 let _ = fs::create_dir_all(&sub_dir);
3986 let html_path = sub_dir.join(format!("{artifact_key}.html"));
3987 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
3988 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
3991 let _ = write_pdf_from_run(&sub_run, &pdf_path);
3992 Some(format!("/runs/{artifact_key}/{run_id}"))
3993 } else {
3994 None
3995 }
3996 })
3997 } else {
3998 None
3999 };
4000 SubmoduleRow {
4001 name: s.name.clone(),
4002 relative_path: s.relative_path.clone(),
4003 files_analyzed: s.files_analyzed,
4004 code_lines: s.code_lines,
4005 comment_lines: s.comment_lines,
4006 blank_lines: s.blank_lines,
4007 total_physical_lines: s.total_physical_lines,
4008 html_url,
4009 }
4010}
4011
4012#[allow(clippy::similar_names)]
4015#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
4017async fn analyze_handler(
4018 State(state): State<AppState>,
4019 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4020 Form(form): Form<AnalyzeForm>,
4021) -> impl IntoResponse {
4022 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4023 let template = ErrorTemplate {
4024 message: format!(
4025 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4026 Please wait a moment and try again."
4027 ),
4028 last_report_url: None,
4029 last_report_label: None,
4030 run_id: None,
4031 error_code: Some(503),
4032 csp_nonce: csp_nonce.clone(),
4033 version: env!("CARGO_PKG_VERSION"),
4034 };
4035 return (
4036 StatusCode::SERVICE_UNAVAILABLE,
4037 Html(
4038 template
4039 .render()
4040 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4041 ),
4042 )
4043 .into_response();
4044 };
4045
4046 let mut config = state.base_config.clone();
4047
4048 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4049 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4050 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4051
4052 if !is_git_mode {
4053 let resolved_path = resolve_input_path(&form.path);
4054 if state.server_mode
4055 && !is_upload_tmp_path(&resolved_path)
4056 && !is_sample_path(&resolved_path)
4057 {
4058 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4059 return resp;
4060 }
4061 }
4062 config.discovery.root_paths = vec![resolved_path];
4063 }
4064
4065 apply_form_to_config(&mut config, &form);
4066 apply_output_dir_exclusions(
4067 &mut config,
4068 &form.path,
4069 form.output_dir.as_deref().unwrap_or(""),
4070 );
4071
4072 let wait_id = uuid::Uuid::new_v4().to_string();
4074 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4075
4076 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4078 let task_cancel = Arc::clone(&cancel_token);
4079
4080 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4082 let task_phase = Arc::clone(&phase);
4083
4084 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4085 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4086 let task_files_done = Arc::clone(&files_done);
4087 let task_files_total = Arc::clone(&files_total);
4088
4089 {
4092 let mut runs = state.async_runs.lock().await;
4093 runs.insert(
4094 wait_id.clone(),
4095 AsyncRunState::Running {
4096 started_at: std::time::Instant::now(),
4097 cancel_token,
4098 phase,
4099 files_done,
4100 files_total,
4101 },
4102 );
4103 }
4104
4105 let task = AnalysisTask {
4106 sem_permit,
4107 state: state.clone(),
4108 wait_id: wait_id.clone(),
4109 config,
4110 cancel: task_cancel,
4111 phase: task_phase,
4112 files_done: task_files_done,
4113 files_total: task_files_total,
4114 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4115 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4116 project_path: form.path.clone(),
4117 output_dir: if state.server_mode {
4121 None
4122 } else {
4123 form.output_dir.clone()
4124 },
4125 clones_dir: state.git_clones_dir.clone(),
4126 };
4127
4128 tokio::spawn(run_analysis_task(task));
4129
4130 let template = ScanWaitTemplate {
4131 version: env!("CARGO_PKG_VERSION"),
4132 wait_id_json,
4133 project_path: form.path.clone(),
4134 csp_nonce,
4135 };
4136 let html = template
4137 .render()
4138 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4139 let mut response = Html(html).into_response();
4140 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4141 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4142 response.headers_mut().insert(name, val);
4143 }
4144 }
4145 response
4146}
4147
4148struct AnalysisTask {
4149 sem_permit: tokio::sync::OwnedSemaphorePermit,
4150 state: AppState,
4151 wait_id: String,
4152 config: AppConfig,
4153 cancel: Arc<std::sync::atomic::AtomicBool>,
4154 phase: Arc<std::sync::Mutex<String>>,
4155 files_done: Arc<std::sync::atomic::AtomicUsize>,
4156 files_total: Arc<std::sync::atomic::AtomicUsize>,
4157 git_repo: Option<String>,
4158 git_ref: Option<String>,
4159 project_path: String,
4160 output_dir: Option<String>,
4161 clones_dir: PathBuf,
4162}
4163
4164#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
4166 let _permit = task.sem_permit;
4167
4168 let cancel_sb = Arc::clone(&task.cancel);
4169 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4170 let clones_dir_sb = task.clones_dir;
4171 let upload_staging_root = task
4173 .config
4174 .discovery
4175 .root_paths
4176 .first()
4177 .filter(|p| is_upload_tmp_path(p))
4178 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4179 .map(PathBuf::from);
4180 let config_sb = task.config;
4181 let progress_sb = sloc_core::ProgressCounters {
4182 files_done: Arc::clone(&task.files_done),
4183 files_total: Arc::clone(&task.files_total),
4184 };
4185 if let Ok(mut p) = task.phase.lock() {
4186 *p = "Scanning files".to_string();
4187 }
4188 let analysis_result = tokio::task::spawn_blocking(move || {
4189 run_analysis_blocking(
4190 config_sb,
4191 git_repo_sb,
4192 git_ref_sb,
4193 clones_dir_sb,
4194 cancel_sb,
4195 Some(progress_sb),
4196 )
4197 })
4198 .await
4199 .map_err(|err| anyhow::anyhow!(err.to_string()))
4200 .and_then(|result| result);
4201
4202 if let Ok(mut p) = task.phase.lock() {
4203 *p = "Writing reports".to_string();
4204 }
4205
4206 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4208 let mut runs = task.state.async_runs.lock().await;
4209 if matches!(
4211 runs.get(&task.wait_id),
4212 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4213 ) {
4214 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4215 }
4216 drop(runs);
4217 return;
4218 }
4219
4220 let run = match analysis_result {
4221 Ok(v) => v,
4222 Err(err) => {
4223 if err.to_string().contains("analysis cancelled") {
4225 let mut runs = task.state.async_runs.lock().await;
4226 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4227 drop(runs);
4228 return;
4229 }
4230 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4231 let mut runs = task.state.async_runs.lock().await;
4232 runs.insert(
4233 task.wait_id.clone(),
4234 AsyncRunState::Failed {
4235 message: "Analysis failed. Check that the path exists and is readable."
4236 .to_string(),
4237 },
4238 );
4239 drop(runs);
4240 return;
4241 }
4242 };
4243
4244 let run_id = run.tool.run_id.clone();
4245 tracing::info!(event = "scan_complete", run_id = %run_id,
4246 path = %task.project_path, files = run.summary_totals.files_analyzed,
4247 "Analysis finished");
4248
4249 let prev_entry: Option<RegistryEntry> = {
4250 let reg = task.state.registry.lock().await;
4251 reg.entries_for_roots(&run.input_roots)
4252 .into_iter()
4253 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4254 .cloned()
4255 };
4256
4257 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4258 prev.json_path
4259 .as_ref()
4260 .and_then(|p| read_json(p).ok())
4261 .map(|prev_run| compute_delta(&prev_run, &run))
4262 });
4263 let prev_scan_count: usize = {
4264 let reg = task.state.registry.lock().await;
4265 reg.entries_for_roots(&run.input_roots)
4266 .iter()
4267 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4268 .count()
4269 };
4270
4271 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4274 .as_ref()
4275 .zip(prev_entry.as_ref())
4276 .map(|(cmp, prev)| ReportDeltaContext {
4277 delta_code_added: sum_added_code_lines(cmp),
4278 delta_code_removed: sum_removed_code_lines(cmp),
4279 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4280 delta_files_added: cmp.files_added,
4281 delta_files_removed: cmp.files_removed,
4282 delta_files_modified: cmp.files_modified,
4283 delta_files_unchanged: cmp.files_unchanged,
4284 prev_code_lines: prev.summary.code_lines,
4285 prev_scan_count: prev_scan_count + 1,
4286 prev_scan_label: fmt_la_time(prev.timestamp_utc),
4287 prev_run_id: Some(prev.run_id.clone()),
4288 current_run_id: Some(run_id.clone()),
4289 });
4290 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4291 Ok(h) => h,
4292 Err(err) => {
4293 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4294 let mut runs = task.state.async_runs.lock().await;
4295 runs.insert(
4296 task.wait_id.clone(),
4297 AsyncRunState::Failed {
4298 message: "Failed to render HTML report.".to_string(),
4299 },
4300 );
4301 drop(runs);
4302 return;
4303 }
4304 };
4305
4306 let output_root = resolve_output_root(task.output_dir.as_deref());
4307 let project_label = derive_project_label(
4308 task.git_repo.as_deref(),
4309 task.git_ref.as_deref(),
4310 &task.project_path,
4311 );
4312 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4313 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4314
4315 let result_context = RunResultContext {
4316 prev_entry: prev_entry.clone(),
4317 prev_scan_count,
4318 project_path: task.project_path.clone(),
4319 };
4320
4321 let artifact_result = persist_run_artifacts(
4322 &run,
4323 &report_html,
4324 &run_dir,
4325 &run.effective_configuration.reporting.report_title,
4326 &file_stem,
4327 result_context,
4328 );
4329
4330 let (artifacts, pending_pdf) = match artifact_result {
4331 Ok(v) => v,
4332 Err(err) => {
4333 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4334 let mut runs = task.state.async_runs.lock().await;
4335 runs.insert(
4336 task.wait_id.clone(),
4337 AsyncRunState::Failed {
4338 message: "Failed to save report artifacts. Check available disk space."
4339 .to_string(),
4340 },
4341 );
4342 drop(runs);
4343 return;
4344 }
4345 };
4346
4347 {
4348 let mut map = task.state.artifacts.lock().await;
4349 map.insert(run_id.clone(), artifacts.clone());
4350 }
4351
4352 {
4353 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4354 let mut reg = task.state.registry.lock().await;
4355 reg.add_entry(entry);
4356 let _ = reg.save(&task.state.registry_path);
4357 }
4358
4359 if let Some(ref cfg_path) = artifacts.scan_config_path {
4360 save_scan_config_json(
4361 cfg_path,
4362 &run,
4363 &task.project_path,
4364 task.output_dir.as_deref(),
4365 );
4366 }
4367
4368 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4369
4370 prom_runs_total().inc();
4371
4372 let mut runs = task.state.async_runs.lock().await;
4374 runs.insert(
4375 task.wait_id.clone(),
4376 AsyncRunState::Complete {
4377 run_id: run_id.clone(),
4378 },
4379 );
4380 drop(runs);
4381
4382 if let Some(staging) = upload_staging_root {
4385 let _ = tokio::fs::remove_dir_all(staging).await;
4386 }
4387
4388 let _ = scan_delta;
4389}
4390
4391fn save_scan_config_json(
4392 cfg_path: &std::path::Path,
4393 run: &sloc_core::AnalysisRun,
4394 project_path: &str,
4395 output_dir: Option<&str>,
4396) {
4397 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4398 .ok()
4399 .and_then(|v| v.as_str().map(String::from))
4400 .unwrap_or_else(|| "code_only".to_string());
4401 let behavior_str =
4402 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4403 .ok()
4404 .and_then(|v| v.as_str().map(String::from))
4405 .unwrap_or_else(|| "skip".to_string());
4406 let scan_cfg = ScanConfig {
4407 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4408 path: project_path.to_string(),
4409 include_globs: run
4410 .effective_configuration
4411 .discovery
4412 .include_globs
4413 .join("\n"),
4414 exclude_globs: run
4415 .effective_configuration
4416 .discovery
4417 .exclude_globs
4418 .join("\n"),
4419 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4420 mixed_line_policy: policy_str,
4421 python_docstrings_as_comments: run
4422 .effective_configuration
4423 .analysis
4424 .python_docstrings_as_comments,
4425 generated_file_detection: run
4426 .effective_configuration
4427 .analysis
4428 .generated_file_detection,
4429 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4430 vendor_directory_detection: run
4431 .effective_configuration
4432 .analysis
4433 .vendor_directory_detection,
4434 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4435 binary_file_behavior: behavior_str,
4436 output_dir: output_dir.unwrap_or("").to_string(),
4437 report_title: run.effective_configuration.reporting.report_title.clone(),
4438 };
4439 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4440 let _ = std::fs::write(cfg_path, json);
4441 }
4442}
4443
4444#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
4446 mut config: AppConfig,
4447 git_repo: Option<String>,
4448 git_ref: Option<String>,
4449 clones_dir: PathBuf,
4450 cancel: Arc<std::sync::atomic::AtomicBool>,
4451 progress: Option<sloc_core::ProgressCounters>,
4452) -> Result<sloc_core::AnalysisRun> {
4453 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4454 let dest = git_clone_dest(&repo, &clones_dir);
4455 sloc_git::clone_or_fetch(&repo, &dest)?;
4456 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4457 sloc_git::create_worktree(&dest, &refname, &wt)?;
4458 config.discovery.root_paths = vec![wt.clone()];
4459 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4460 let _ = sloc_git::destroy_worktree(&dest, &wt);
4461 let mut run = run?;
4462 if run.git_branch.is_none() {
4463 run.git_branch = Some(refname);
4464 }
4465 return Ok(run);
4466 }
4467 analyze(&config, "serve", Some(&cancel), progress.as_ref())
4468}
4469
4470fn derive_project_label(
4471 git_repo: Option<&str>,
4472 git_ref: Option<&str>,
4473 fallback_path: &str,
4474) -> String {
4475 match (
4476 git_repo.filter(|s| !s.is_empty()),
4477 git_ref.filter(|s| !s.is_empty()),
4478 ) {
4479 (Some(repo), Some(refname)) => {
4480 let repo_name = repo
4481 .trim_end_matches('/')
4482 .trim_end_matches(".git")
4483 .rsplit('/')
4484 .next()
4485 .unwrap_or("repo");
4486 sanitize_project_label(&format!("{repo_name}_{refname}"))
4487 }
4488 _ => sanitize_project_label(fallback_path),
4489 }
4490}
4491
4492fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4493 let commit = commit_short.unwrap_or("").trim();
4494 if commit.is_empty() {
4495 project_label.to_string()
4496 } else {
4497 format!("{project_label}_{commit}")
4498 }
4499}
4500
4501#[derive(Serialize)]
4504#[serde(tag = "state", rename_all = "snake_case")]
4505enum AsyncRunStatusResponse {
4506 Running {
4507 elapsed_secs: u64,
4508 phase: String,
4509 files_done: u64,
4510 files_total: u64,
4511 },
4512 Complete {
4513 run_id: String,
4514 },
4515 Failed {
4516 message: String,
4517 },
4518 Cancelled,
4519}
4520
4521async fn async_run_status_handler(
4522 State(state): State<AppState>,
4523 AxumPath(wait_id): AxumPath<String>,
4524) -> Response {
4525 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4527 return error::bad_request("invalid wait_id");
4528 }
4529 let run_state = {
4530 let runs = state.async_runs.lock().await;
4531 runs.get(&wait_id).cloned()
4532 };
4533 match run_state {
4534 None => error::not_found("run not found"),
4535 Some(AsyncRunState::Running {
4536 started_at,
4537 phase,
4538 files_done,
4539 files_total,
4540 ..
4541 }) => {
4542 if started_at.elapsed() > std::time::Duration::from_hours(2) {
4544 let mut runs = state.async_runs.lock().await;
4545 runs.insert(
4546 wait_id,
4547 AsyncRunState::Failed {
4548 message: "Analysis timed out after 2 hours.".to_string(),
4549 },
4550 );
4551 drop(runs);
4552 return Json(AsyncRunStatusResponse::Failed {
4553 message: "Analysis timed out after 2 hours.".to_string(),
4554 })
4555 .into_response();
4556 }
4557 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4558 Json(AsyncRunStatusResponse::Running {
4559 elapsed_secs: started_at.elapsed().as_secs(),
4560 phase: phase_str,
4561 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4562 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4563 })
4564 .into_response()
4565 }
4566 Some(AsyncRunState::Complete { run_id }) => {
4567 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4568 }
4569 Some(AsyncRunState::Failed { message }) => {
4570 Json(AsyncRunStatusResponse::Failed { message }).into_response()
4571 }
4572 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4573 }
4574}
4575
4576async fn cancel_run_handler(
4577 State(state): State<AppState>,
4578 AxumPath(wait_id): AxumPath<String>,
4579) -> Response {
4580 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4581 return error::bad_request("invalid wait_id");
4582 }
4583 let mut runs = state.async_runs.lock().await;
4584 let resp = match runs.get(&wait_id) {
4585 Some(AsyncRunState::Running { cancel_token, .. }) => {
4586 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4587 runs.insert(wait_id, AsyncRunState::Cancelled);
4588 StatusCode::OK.into_response()
4589 }
4590 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4591 _ => error::not_found("run not found"),
4592 };
4593 drop(runs);
4594 resp
4595}
4596
4597async fn async_run_result_handler(
4598 State(state): State<AppState>,
4599 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4600 AxumPath(run_id): AxumPath<String>,
4601) -> Response {
4602 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4603 return StatusCode::BAD_REQUEST.into_response();
4604 }
4605
4606 let artifacts = {
4607 let map = state.artifacts.lock().await;
4608 map.get(&run_id).cloned()
4609 };
4610 let artifacts = if let Some(a) = artifacts {
4611 a
4612 } else {
4613 let reg = state.registry.lock().await;
4614 if let Some(entry) = reg.find_by_run_id(&run_id) {
4615 recover_artifacts_from_registry(entry)
4616 } else {
4617 let html = ErrorTemplate {
4618 message: format!(
4619 "Report not found. Run ID {} is not in the scan history.",
4620 &run_id[..run_id.len().min(8)]
4621 ),
4622 last_report_url: Some("/view-reports".to_string()),
4623 last_report_label: Some("View Reports".to_string()),
4624 run_id: Some(run_id.clone()),
4625 error_code: Some(404),
4626 csp_nonce: csp_nonce.clone(),
4627 version: env!("CARGO_PKG_VERSION"),
4628 }
4629 .render()
4630 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4631 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4632 }
4633 };
4634
4635 let json_path = if let Some(p) = &artifacts.json_path {
4636 p.clone()
4637 } else {
4638 let html = ErrorTemplate {
4639 message: "JSON result was not saved for this run.".to_string(),
4640 last_report_url: Some("/view-reports".to_string()),
4641 last_report_label: Some("View Reports".to_string()),
4642 run_id: Some(run_id.clone()),
4643 error_code: Some(404),
4644 csp_nonce: csp_nonce.clone(),
4645 version: env!("CARGO_PKG_VERSION"),
4646 }
4647 .render()
4648 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4649 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4650 };
4651
4652 let Ok(run) = read_json(&json_path) else {
4653 let folder_hint = json_path
4654 .parent()
4655 .map(|p| p.display().to_string())
4656 .unwrap_or_default();
4657 let redirect_url = format!("/runs/result/{run_id}");
4658 return missing_scan_relocate_response(
4659 &format!(
4660 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
4661 deleted. Browse to the folder containing your scan output to reconnect it.",
4662 json_path.display()
4663 ),
4664 &run_id,
4665 &folder_hint,
4666 &redirect_url,
4667 state.server_mode,
4668 &csp_nonce,
4669 );
4670 };
4671
4672 let confluence_configured = {
4673 let store = state.confluence.lock().await;
4674 store.is_configured()
4675 };
4676
4677 render_result_page(
4678 &run,
4679 &artifacts,
4680 &run_id,
4681 &csp_nonce,
4682 confluence_configured,
4683 state.server_mode,
4684 )
4685}
4686
4687#[allow(clippy::too_many_lines)]
4688#[allow(clippy::similar_names)] fn render_result_page(
4690 run: &AnalysisRun,
4691 artifacts: &RunArtifacts,
4692 run_id: &str,
4693 csp_nonce: &str,
4694 confluence_configured: bool,
4695 server_mode: bool,
4696) -> Response {
4697 let ctx = &artifacts.result_context;
4698 let prev_entry = &ctx.prev_entry;
4699 let prev_scan_count = ctx.prev_scan_count;
4700 let project_path = &ctx.project_path;
4701
4702 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4703 prev.json_path
4704 .as_ref()
4705 .and_then(|p| read_json(p).ok())
4706 .map(|prev_run| compute_delta(&prev_run, run))
4707 });
4708
4709 let files_analyzed = run.per_file_records.len() as u64;
4710 let files_skipped = run.skipped_file_records.len() as u64;
4711 let physical_lines = run
4712 .totals_by_language
4713 .iter()
4714 .map(|r| r.total_physical_lines)
4715 .sum::<u64>();
4716 let code_lines = run
4717 .totals_by_language
4718 .iter()
4719 .map(|r| r.code_lines)
4720 .sum::<u64>();
4721 let comment_lines = run
4722 .totals_by_language
4723 .iter()
4724 .map(|r| r.comment_lines)
4725 .sum::<u64>();
4726 let blank_lines = run
4727 .totals_by_language
4728 .iter()
4729 .map(|r| r.blank_lines)
4730 .sum::<u64>();
4731 let mixed_lines = run
4732 .totals_by_language
4733 .iter()
4734 .map(|r| r.mixed_lines_separate)
4735 .sum::<u64>();
4736 let functions = run
4737 .totals_by_language
4738 .iter()
4739 .map(|r| r.functions)
4740 .sum::<u64>();
4741 let classes = run
4742 .totals_by_language
4743 .iter()
4744 .map(|r| r.classes)
4745 .sum::<u64>();
4746 let variables = run
4747 .totals_by_language
4748 .iter()
4749 .map(|r| r.variables)
4750 .sum::<u64>();
4751 let imports = run
4752 .totals_by_language
4753 .iter()
4754 .map(|r| r.imports)
4755 .sum::<u64>();
4756
4757 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4758 let prev_fa = prev_sum.map(|s| s.files_analyzed);
4759 let prev_fs = prev_sum.map(|s| s.files_skipped);
4760 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4761 let prev_cl = prev_sum.map(|s| s.code_lines);
4762 let prev_cml = prev_sum.map(|s| s.comment_lines);
4763 let prev_bl = prev_sum.map(|s| s.blank_lines);
4764 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4765 let prev_fa_str = fmt_prev(prev_fa);
4766 let prev_fs_str = fmt_prev(prev_fs);
4767 let prev_pl_str = fmt_prev(prev_pl);
4768 let prev_cl_str = fmt_prev(prev_cl);
4769 let prev_cml_str = fmt_prev(prev_cml);
4770 let prev_bl_str = fmt_prev(prev_bl);
4771 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4772 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4773 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4774 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4775 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4776 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4777 let delta_fa_class = delta_fa_class.to_string();
4778 let delta_fs_class = delta_fs_class.to_string();
4779 let delta_pl_class = delta_pl_class.to_string();
4780 let delta_cl_class = delta_cl_class.to_string();
4781 let delta_cml_class = delta_cml_class.to_string();
4782 let delta_bl_class = delta_bl_class.to_string();
4783
4784 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4785 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4786 let (delta_lines_net_str, delta_lines_net_class) =
4787 match (delta_lines_added, delta_lines_removed) {
4788 (Some(a), Some(r)) => {
4789 let net = a - r;
4790 (fmt_delta(net), delta_class(net).to_string())
4791 }
4792 _ => ("—".to_string(), "na".to_string()),
4793 };
4794
4795 let run_dir = artifacts.output_dir.clone();
4796 let git_branch = run.git_branch.clone();
4797 let git_commit = run.git_commit_short.clone();
4798 let git_commit_long = run.git_commit_long.clone();
4799 let git_author = run.git_commit_author.clone();
4800 let git_commit_url = run
4801 .git_remote_url
4802 .as_deref()
4803 .zip(run.git_commit_long.as_deref())
4804 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4805 let git_branch_url = run
4806 .git_remote_url
4807 .as_deref()
4808 .zip(run.git_branch.as_deref())
4809 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
4810 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4811 format!(
4812 "{} / {}",
4813 run.environment.initiator_username, run.environment.initiator_hostname
4814 )
4815 });
4816 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
4817 let os_display = format!(
4818 "{} / {}",
4819 run.environment.operating_system, run.environment.architecture
4820 );
4821 let test_count = run.summary_totals.test_count;
4822
4823 let template = ResultTemplate {
4824 version: env!("CARGO_PKG_VERSION"),
4825 report_title: run.effective_configuration.reporting.report_title.clone(),
4826 project_path: project_path.clone(),
4827 output_dir: display_path(&artifacts.output_dir),
4828 run_id: run_id.to_owned(),
4829 run_id_short: run_id
4830 .split('-')
4831 .next_back()
4832 .unwrap_or(run_id)
4833 .chars()
4834 .take(7)
4835 .collect(),
4836 files_analyzed,
4837 files_skipped,
4838 physical_lines,
4839 code_lines,
4840 comment_lines,
4841 blank_lines,
4842 mixed_lines,
4843 functions,
4844 classes,
4845 variables,
4846 imports,
4847 html_url: artifacts
4848 .html_path
4849 .as_ref()
4850 .map(|_| format!("/runs/html/{run_id}")),
4851 pdf_url: artifacts
4852 .pdf_path
4853 .as_ref()
4854 .map(|_| format!("/runs/pdf/{run_id}")),
4855 json_url: artifacts
4856 .json_path
4857 .as_ref()
4858 .map(|_| format!("/runs/json/{run_id}")),
4859 html_download_url: artifacts
4860 .html_path
4861 .as_ref()
4862 .map(|_| format!("/runs/html/{run_id}?download=1")),
4863 pdf_download_url: artifacts
4864 .pdf_path
4865 .as_ref()
4866 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4867 json_download_url: artifacts
4868 .json_path
4869 .as_ref()
4870 .map(|_| format!("/runs/json/{run_id}?download=1")),
4871 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4872 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4873 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4874 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4875 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4876 prev_fa_str,
4877 prev_fs_str,
4878 prev_pl_str,
4879 prev_cl_str,
4880 prev_cml_str,
4881 prev_bl_str,
4882 delta_fa_str,
4883 delta_fa_class,
4884 delta_fs_str,
4885 delta_fs_class,
4886 delta_pl_str,
4887 delta_pl_class,
4888 delta_cl_str,
4889 delta_cl_class,
4890 delta_cml_str,
4891 delta_cml_class,
4892 delta_bl_str,
4893 delta_bl_class,
4894 delta_lines_added,
4895 delta_lines_removed,
4896 delta_lines_net_str,
4897 delta_lines_net_class,
4898 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4899 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4900 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4901 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4902 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4903 d.file_deltas
4904 .iter()
4905 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4906 .map(|f| {
4907 #[allow(clippy::cast_sign_loss)]
4908 let n = f.current_code as u64;
4909 n
4910 })
4911 .sum()
4912 }),
4913 git_branch,
4914 git_branch_url,
4915 git_commit,
4916 git_commit_long,
4917 git_author,
4918 git_commit_url,
4919 scan_performed_by,
4920 scan_time_display,
4921 os_display,
4922 test_count,
4923 current_scan_number: prev_scan_count + 1,
4924 prev_scan_count,
4925 submodule_rows: run
4926 .submodule_summaries
4927 .iter()
4928 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
4929 .collect(),
4930 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4931 scan_config_url: format!("/runs/scan-config/{run_id}"),
4932 lang_chart_json: {
4933 let mut langs: Vec<&sloc_core::LanguageSummary> =
4934 run.totals_by_language.iter().collect();
4935 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
4936 let entries: Vec<String> = langs
4937 .into_iter()
4938 .take(12)
4939 .map(|l| {
4940 let name = l
4941 .language
4942 .display_name()
4943 .replace('\\', "\\\\")
4944 .replace('"', "\\\"");
4945 format!(
4946 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4947 name,
4948 l.code_lines,
4949 l.comment_lines,
4950 l.blank_lines,
4951 l.total_physical_lines,
4952 l.functions,
4953 l.classes,
4954 l.variables,
4955 l.imports,
4956 l.files,
4957 )
4958 })
4959 .collect();
4960 format!("[{}]", entries.join(","))
4961 },
4962 scatter_chart_json: {
4963 let entries: Vec<String> = run
4964 .totals_by_language
4965 .iter()
4966 .map(|l| {
4967 let name = l
4968 .language
4969 .display_name()
4970 .replace('\\', "\\\\")
4971 .replace('"', "\\\"");
4972 format!(
4973 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4974 name, l.files, l.code_lines, l.total_physical_lines,
4975 )
4976 })
4977 .collect();
4978 format!("[{}]", entries.join(","))
4979 },
4980 semantic_chart_json: {
4981 let entries: Vec<String> = run
4982 .totals_by_language
4983 .iter()
4984 .filter(|l| {
4985 l.functions > 0
4986 || l.classes > 0
4987 || l.variables > 0
4988 || l.imports > 0
4989 || l.test_count > 0
4990 })
4991 .map(|l| {
4992 let name = l
4993 .language
4994 .display_name()
4995 .replace('\\', "\\\\")
4996 .replace('"', "\\\"");
4997 format!(
4998 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
4999 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5000 )
5001 })
5002 .collect();
5003 format!("[{}]", entries.join(","))
5004 },
5005 submodule_chart_json: {
5006 let entries: Vec<String> = run
5007 .submodule_summaries
5008 .iter()
5009 .map(|s| {
5010 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
5011 format!(
5012 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5013 name,
5014 s.code_lines,
5015 s.comment_lines,
5016 s.blank_lines,
5017 s.total_physical_lines,
5018 s.files_analyzed,
5019 )
5020 })
5021 .collect();
5022 format!("[{}]", entries.join(","))
5023 },
5024 has_submodule_data: !run.submodule_summaries.is_empty(),
5025 has_semantic_data: run
5026 .totals_by_language
5027 .iter()
5028 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5029 csp_nonce: csp_nonce.to_owned(),
5030 confluence_configured,
5031 server_mode,
5032 report_header_footer: run
5033 .effective_configuration
5034 .reporting
5035 .report_header_footer
5036 .clone(),
5037 is_offline: false,
5038 };
5039
5040 Html(
5041 template
5042 .render()
5043 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5044 )
5045 .into_response()
5046}
5047
5048fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5049 let slug: String = report_title
5050 .chars()
5051 .map(|c| {
5052 if c.is_alphanumeric() || c == '-' {
5053 c.to_ascii_lowercase()
5054 } else {
5055 '_'
5056 }
5057 })
5058 .collect::<String>()
5059 .split('_')
5060 .filter(|s| !s.is_empty())
5061 .collect::<Vec<_>>()
5062 .join("_");
5063
5064 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5065
5066 if slug.is_empty() {
5067 format!("report_{short_id}.pdf")
5068 } else {
5069 format!("{slug}_{short_id}.pdf")
5070 }
5071}
5072
5073#[derive(Serialize)]
5074struct PdfStatusResponse {
5075 ready: bool,
5076}
5077
5078async fn pdf_status_handler(
5081 State(state): State<AppState>,
5082 AxumPath(run_id): AxumPath<String>,
5083) -> Response {
5084 let pdf_path = {
5085 let registry = state.artifacts.lock().await;
5086 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5087 };
5088 let pdf_path = if pdf_path.is_some() {
5089 pdf_path
5090 } else {
5091 let reg = state.registry.lock().await;
5092 reg.find_by_run_id(&run_id)
5093 .map(recover_artifacts_from_registry)
5094 .and_then(|a| a.pdf_path)
5095 };
5096 let ready = pdf_path.is_some_and(|p| p.exists());
5097 Json(PdfStatusResponse { ready }).into_response()
5098}
5099
5100async fn download_bundle_handler(
5106 State(state): State<AppState>,
5107 AxumPath(run_id): AxumPath<String>,
5108) -> Response {
5109 let output_dir = {
5111 let cache = state.artifacts.lock().await;
5112 cache.get(&run_id).map(|a| a.output_dir.clone())
5113 };
5114 let output_dir = if let Some(d) = output_dir {
5115 d
5116 } else {
5117 let reg = state.registry.lock().await;
5118 match reg.find_by_run_id(&run_id) {
5119 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5120 None => {
5121 return (
5122 StatusCode::NOT_FOUND,
5123 Json(serde_json::json!({"error": "Run not found"})),
5124 )
5125 .into_response();
5126 }
5127 }
5128 };
5129
5130 if !output_dir.exists() {
5131 return (
5132 StatusCode::NOT_FOUND,
5133 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5134 )
5135 .into_response();
5136 }
5137
5138 let run_id_clone = run_id.clone();
5140 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5141 use flate2::{write::GzEncoder, Compression};
5142 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5143 {
5144 let mut tar = tar::Builder::new(&mut enc);
5145 tar.follow_symlinks(false);
5146 if let Ok(entries) = std::fs::read_dir(&output_dir) {
5149 for entry in entries.filter_map(Result::ok) {
5150 let p = entry.path();
5151 if p.is_file() {
5152 let name = p.file_name().unwrap_or_default().to_string_lossy();
5153 let archive_path = format!("{run_id_clone}/{name}");
5154 tar.append_path_with_name(&p, &archive_path)?;
5155 }
5156 }
5157 }
5158 tar.finish()?;
5159 }
5160 Ok(enc.finish()?)
5161 })
5162 .await;
5163
5164 match archive_result {
5165 Ok(Ok(bytes)) => {
5166 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5167 axum::response::Response::builder()
5168 .status(StatusCode::OK)
5169 .header("Content-Type", "application/gzip")
5170 .header(
5171 "Content-Disposition",
5172 format!("attachment; filename=\"{filename}\""),
5173 )
5174 .header("Content-Length", bytes.len().to_string())
5175 .body(axum::body::Body::from(bytes))
5176 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5177 }
5178 Ok(Err(e)) => (
5179 StatusCode::INTERNAL_SERVER_ERROR,
5180 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5181 )
5182 .into_response(),
5183 Err(e) => (
5184 StatusCode::INTERNAL_SERVER_ERROR,
5185 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5186 )
5187 .into_response(),
5188 }
5189}
5190
5191async fn delete_run_handler(
5196 State(state): State<AppState>,
5197 AxumPath(run_id): AxumPath<String>,
5198) -> Response {
5199 let output_dir = {
5201 let mut cache = state.artifacts.lock().await;
5202 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5203 cache.remove(&run_id);
5204 dir
5205 };
5206 let output_dir = if let Some(d) = output_dir {
5207 d
5208 } else {
5209 let reg = state.registry.lock().await;
5210 reg.find_by_run_id(&run_id)
5211 .map(|e| recover_artifacts_from_registry(e).output_dir)
5212 .unwrap_or_default()
5213 };
5214
5215 {
5217 let mut reg = state.registry.lock().await;
5218 reg.entries.retain(|e| e.run_id != run_id);
5219 let _ = reg.save(&state.registry_path);
5220 }
5221
5222 if output_dir.exists() {
5224 if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
5225 return (
5226 StatusCode::INTERNAL_SERVER_ERROR,
5227 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5228 )
5229 .into_response();
5230 }
5231 }
5232
5233 StatusCode::NO_CONTENT.into_response()
5234}
5235
5236async fn cleanup_runs_handler(
5241 State(state): State<AppState>,
5242 Json(body): Json<serde_json::Value>,
5243) -> Response {
5244 let days = body
5245 .get("older_than_days")
5246 .and_then(serde_json::Value::as_u64)
5247 .unwrap_or(30)
5248 .max(1);
5249
5250 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5251
5252 let expired: Vec<(String, PathBuf)> = {
5254 let reg = state.registry.lock().await;
5255 reg.entries
5256 .iter()
5257 .filter(|e| e.timestamp_utc < cutoff)
5258 .map(|e| {
5259 let arts = recover_artifacts_from_registry(e);
5260 (e.run_id.clone(), arts.output_dir)
5261 })
5262 .collect()
5263 };
5264
5265 let mut deleted = 0usize;
5266 for (run_id, output_dir) in &expired {
5267 state.artifacts.lock().await.remove(run_id);
5269 if output_dir.exists() {
5271 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5272 eprintln!(
5273 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5274 output_dir.display()
5275 );
5276 continue;
5277 }
5278 }
5279 deleted += 1;
5280 }
5281
5282 let expired_ids: std::collections::HashSet<&str> =
5284 expired.iter().map(|(id, _)| id.as_str()).collect();
5285 {
5286 let mut reg = state.registry.lock().await;
5287 reg.entries
5288 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5289 let _ = reg.save(&state.registry_path);
5290 }
5291
5292 Json(serde_json::json!({ "deleted": deleted })).into_response()
5293}
5294
5295fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5298 tokio::spawn(async move {
5299 loop {
5300 let interval_secs = {
5301 let store = state.cleanup_policy.lock().await;
5302 match &store.policy {
5303 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5304 _ => break,
5305 }
5306 };
5307 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5308 let n = run_auto_cleanup(&state).await;
5309 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5310 }
5311 })
5312}
5313
5314fn collect_runs_to_delete(
5315 reg: &ScanRegistry,
5316 max_age_days: Option<u32>,
5317 max_run_count: Option<u32>,
5318) -> std::collections::HashSet<String> {
5319 let mut to_delete = std::collections::HashSet::new();
5320 if let Some(days) = max_age_days {
5321 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5322 for e in ®.entries {
5323 if e.timestamp_utc < cutoff {
5324 to_delete.insert(e.run_id.clone());
5325 }
5326 }
5327 }
5328 if let Some(max_count) = max_run_count {
5329 for e in reg.entries.iter().skip(max_count as usize) {
5331 to_delete.insert(e.run_id.clone());
5332 }
5333 }
5334 to_delete
5335}
5336
5337async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5338 let output_dir = {
5339 let mut cache = state.artifacts.lock().await;
5340 let d = cache.get(run_id).map(|a| a.output_dir.clone());
5341 cache.remove(run_id);
5342 d
5343 };
5344 let output_dir = if let Some(d) = output_dir {
5345 d
5346 } else {
5347 let reg = state.registry.lock().await;
5348 reg.find_by_run_id(run_id)
5349 .map(|e| recover_artifacts_from_registry(e).output_dir)
5350 .unwrap_or_default()
5351 };
5352 if output_dir.exists() {
5353 let _ = tokio::fs::remove_dir_all(&output_dir).await;
5354 }
5355}
5356
5357async fn run_auto_cleanup(state: &AppState) -> u32 {
5361 let (max_age_days, max_run_count) = {
5362 let store = state.cleanup_policy.lock().await;
5363 match &store.policy {
5364 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5365 _ => return 0,
5366 }
5367 };
5368
5369 let to_delete = {
5370 let reg = state.registry.lock().await;
5371 collect_runs_to_delete(®, max_age_days, max_run_count)
5372 };
5373
5374 for run_id in &to_delete {
5375 delete_run_artifacts(state, run_id).await;
5376 }
5377
5378 if !to_delete.is_empty() {
5380 let mut reg = state.registry.lock().await;
5381 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5382 let _ = reg.save(&state.registry_path);
5383 }
5384
5385 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
5386 {
5387 let mut store = state.cleanup_policy.lock().await;
5388 store.last_run_at = Some(chrono::Utc::now());
5389 store.last_run_deleted = Some(deleted);
5390 let _ = store.save(&state.cleanup_policy_path);
5391 }
5392 deleted
5393}
5394
5395async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5399 let store = state.cleanup_policy.lock().await;
5400 Json(serde_json::json!({
5401 "policy": store.policy,
5402 "last_run_at": store.last_run_at,
5403 "last_run_deleted": store.last_run_deleted,
5404 }))
5405 .into_response()
5406}
5407
5408async fn api_save_cleanup_policy(
5410 State(state): State<AppState>,
5411 Json(body): Json<CleanupPolicy>,
5412) -> Response {
5413 {
5415 let mut handle = state.cleanup_task_handle.lock().await;
5416 if let Some(h) = handle.take() {
5417 h.abort();
5418 }
5419 }
5420 {
5421 let mut store = state.cleanup_policy.lock().await;
5422 store.policy = Some(body.clone());
5423 if let Err(e) = store.save(&state.cleanup_policy_path) {
5424 return (
5425 StatusCode::INTERNAL_SERVER_ERROR,
5426 Json(serde_json::json!({"error": e.to_string()})),
5427 )
5428 .into_response();
5429 }
5430 }
5431 if body.enabled {
5432 let handle = spawn_cleanup_policy_task(state.clone());
5433 *state.cleanup_task_handle.lock().await = Some(handle);
5434 }
5435 StatusCode::NO_CONTENT.into_response()
5436}
5437
5438async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5440 let deleted = run_auto_cleanup(&state).await;
5441 Json(serde_json::json!({ "deleted": deleted })).into_response()
5442}
5443
5444async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5446 {
5447 let mut handle = state.cleanup_task_handle.lock().await;
5448 if let Some(h) = handle.take() {
5449 h.abort();
5450 }
5451 }
5452 {
5453 let mut store = state.cleanup_policy.lock().await;
5454 store.policy = None;
5455 let _ = store.save(&state.cleanup_policy_path);
5456 }
5457 StatusCode::NO_CONTENT.into_response()
5458}
5459
5460fn swap_inline_chart_js_for_static(html: String) -> String {
5466 let Some(head_end) = html.find("</head>") else {
5467 return html;
5468 };
5469 let Some(script_start) = html[..head_end].rfind("<script") else {
5470 return html;
5471 };
5472 let Some(close_offset) = html[script_start..].find("</script>") else {
5473 return html;
5474 };
5475 let block_end = script_start + close_offset + "</script>".len();
5476 format!(
5477 "{}<script src=\"/static/chart-report.js\"></script>{}",
5478 &html[..script_start],
5479 &html[block_end..]
5480 )
5481}
5482
5483fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5485 let Some(start) = html.find("nonce=\"") else {
5487 return html
5491 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5492 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5493 };
5494 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
5496 return html.to_owned();
5497 };
5498 let old_nonce = &html[value_start..value_start + end_offset];
5499 html.replace(
5500 &format!("nonce=\"{old_nonce}\""),
5501 &format!("nonce=\"{new_nonce}\""),
5502 )
5503}
5504
5505fn serve_html_artifact(
5506 path: &Path,
5507 wants_download: bool,
5508 csp_nonce: &str,
5509 run_id: &str,
5510 server_mode: bool,
5511) -> Response {
5512 match fs::read_to_string(path) {
5513 Ok(raw) => {
5514 let content = patch_html_nonce(&raw, csp_nonce);
5516 if wants_download {
5517 (
5519 [
5520 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5521 (
5522 header::CONTENT_DISPOSITION,
5523 "attachment; filename=report.html",
5524 ),
5525 ],
5526 content,
5527 )
5528 .into_response()
5529 } else {
5530 Html(swap_inline_chart_js_for_static(content)).into_response()
5533 }
5534 }
5535 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5536 let filename = path.file_name().map_or_else(
5537 || "report.html".to_string(),
5538 |n| n.to_string_lossy().into_owned(),
5539 );
5540 let html = LocateFileTemplate {
5541 run_id: run_id.to_owned(),
5542 artifact_type: "html".to_string(),
5543 expected_filename: filename,
5544 server_mode,
5545 csp_nonce: csp_nonce.to_owned(),
5546 version: env!("CARGO_PKG_VERSION"),
5547 }
5548 .render()
5549 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5550 (StatusCode::NOT_FOUND, Html(html)).into_response()
5551 }
5552 Err(err) => {
5553 let filename = path.file_name().map_or_else(
5554 || "report.html".to_string(),
5555 |n| n.to_string_lossy().into_owned(),
5556 );
5557 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5558 let html = ErrorTemplate {
5559 message: msg,
5560 last_report_url: Some("/view-reports".to_string()),
5561 last_report_label: Some("View Reports".to_string()),
5562 run_id: None,
5563 error_code: Some(404),
5564 csp_nonce: csp_nonce.to_owned(),
5565 version: env!("CARGO_PKG_VERSION"),
5566 }
5567 .render()
5568 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5569 (StatusCode::NOT_FOUND, Html(html)).into_response()
5570 }
5571 }
5572}
5573
5574fn serve_pdf_artifact(
5576 path: &Path,
5577 report_title: &str,
5578 run_id: &str,
5579 wants_download: bool,
5580 csp_nonce: &str,
5581) -> Response {
5582 match fs::read(path) {
5583 Ok(bytes) => {
5584 let filename = build_pdf_filename(report_title, run_id);
5585 let disposition = if wants_download {
5586 format!("attachment; filename=\"{filename}\"")
5587 } else {
5588 format!("inline; filename=\"{filename}\"")
5589 };
5590 (
5591 [
5592 (header::CONTENT_TYPE, "application/pdf".to_string()),
5593 (header::CONTENT_DISPOSITION, disposition),
5594 ],
5595 bytes,
5596 )
5597 .into_response()
5598 }
5599 Err(err) => {
5600 let filename = path.file_name().map_or_else(
5601 || "report.pdf".to_string(),
5602 |n| n.to_string_lossy().into_owned(),
5603 );
5604 let msg = format!(
5605 "PDF report '{filename}' could not be read.\n\n\
5606 Error: {err}\n\n\
5607 If you moved or renamed the output folder, the stored path is now stale. \
5608 Use 'Open PDF folder' from the results page to browse the output directory."
5609 );
5610 let html = ErrorTemplate {
5611 message: msg,
5612 last_report_url: Some("/view-reports".to_string()),
5613 last_report_label: Some("View Reports".to_string()),
5614 run_id: Some(run_id.to_owned()),
5615 error_code: Some(404),
5616 csp_nonce: csp_nonce.to_owned(),
5617 version: env!("CARGO_PKG_VERSION"),
5618 }
5619 .render()
5620 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5621 (StatusCode::NOT_FOUND, Html(html)).into_response()
5622 }
5623 }
5624}
5625
5626fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5628 match fs::read(path) {
5629 Ok(bytes) => {
5630 if wants_download {
5631 (
5632 [
5633 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5634 (
5635 header::CONTENT_DISPOSITION,
5636 "attachment; filename=result.json",
5637 ),
5638 ],
5639 bytes,
5640 )
5641 .into_response()
5642 } else {
5643 (
5644 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5645 bytes,
5646 )
5647 .into_response()
5648 }
5649 }
5650 Err(err) => {
5651 let filename = path.file_name().map_or_else(
5652 || "result.json".to_string(),
5653 |n| n.to_string_lossy().into_owned(),
5654 );
5655 let msg = format!(
5656 "JSON result '{filename}' could not be read.\n\n\
5657 Error: {err}\n\n\
5658 If you moved or renamed the output folder, the stored path is now stale. \
5659 Use 'Open JSON folder' from the results page to browse the output directory."
5660 );
5661 let html = ErrorTemplate {
5662 message: msg,
5663 last_report_url: Some("/view-reports".to_string()),
5664 last_report_label: Some("View Reports".to_string()),
5665 run_id: None,
5666 error_code: Some(404),
5667 csp_nonce: csp_nonce.to_owned(),
5668 version: env!("CARGO_PKG_VERSION"),
5669 }
5670 .render()
5671 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5672 (StatusCode::NOT_FOUND, Html(html)).into_response()
5673 }
5674 }
5675}
5676
5677fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5679 let output_dir = entry
5682 .html_path
5683 .as_ref()
5684 .or(entry.json_path.as_ref())
5685 .or(entry.pdf_path.as_ref())
5686 .or(entry.csv_path.as_ref())
5687 .or(entry.xlsx_path.as_ref())
5688 .and_then(|p| {
5689 let parent = p.parent()?;
5690 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
5691 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
5693 parent.parent().map(PathBuf::from)
5694 } else {
5695 Some(parent.to_path_buf())
5696 }
5697 })
5698 .unwrap_or_default();
5699 let pdf_path = entry.pdf_path.clone().or_else(|| {
5702 let candidate = output_dir.join("report.pdf");
5703 candidate.exists().then_some(candidate)
5704 });
5705 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
5709 for dir in &[output_dir.join("excel"), output_dir.clone()] {
5711 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
5712 entries
5713 .filter_map(std::result::Result::ok)
5714 .find(|e| {
5715 let n = e.file_name();
5716 let n = n.to_string_lossy();
5717 n.starts_with("report_") && n.ends_with(ext)
5718 })
5719 .map(|e| e.path())
5720 }) {
5721 return Some(p);
5722 }
5723 }
5724 None
5725 };
5726
5727 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
5728 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
5729 RunArtifacts {
5730 output_dir: output_dir.clone(),
5731 html_path: entry.html_path.clone(),
5732 pdf_path,
5733 json_path: entry.json_path.clone(),
5734 csv_path,
5735 xlsx_path,
5736 scan_config_path: find_scan_config_in_dir(&output_dir),
5737 report_title: entry.project_label.clone(),
5738 result_context: RunResultContext::default(),
5739 }
5740}
5741
5742#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
5744 state: &AppState,
5745 run_id: &str,
5746 csp_nonce: &str,
5747) -> Result<RunArtifacts, Response> {
5748 let cached = state.artifacts.lock().await.get(run_id).cloned();
5749 if let Some(a) = cached {
5750 return Ok(a);
5751 }
5752 let reg = state.registry.lock().await;
5753 if let Some(entry) = reg.find_by_run_id(run_id) {
5754 return Ok(recover_artifacts_from_registry(entry));
5755 }
5756 drop(reg);
5757 let short_id = &run_id[..run_id.len().min(8)];
5758 let hint = if matches!(
5759 run_id,
5760 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
5761 ) {
5762 format!(
5763 " The URL format appears to be reversed — \
5764 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
5765 Use the View Reports page to navigate to your scan."
5766 )
5767 } else {
5768 " The report may have been deleted or the report directory moved. \
5769 Use View Reports to browse your scan history."
5770 .to_string()
5771 };
5772 let error_html = ErrorTemplate {
5773 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
5774 last_report_url: Some("/view-reports".to_string()),
5775 last_report_label: Some("View Reports".to_string()),
5776 run_id: None,
5777 error_code: Some(404),
5778 csp_nonce: csp_nonce.to_owned(),
5779 version: env!("CARGO_PKG_VERSION"),
5780 }
5781 .render()
5782 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5783 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
5784}
5785
5786async fn resolve_or_queue_pdf(
5791 state: &AppState,
5792 pdf_path: Option<PathBuf>,
5793 json_path: Option<PathBuf>,
5794 output_dir: PathBuf,
5795 run_id: &str,
5796 report_title: &str,
5797 csp_nonce: &str,
5798) -> Result<PathBuf, Response> {
5799 if let Some(p) = pdf_path {
5800 return Ok(p);
5801 }
5802 let Some(json_src) = json_path.filter(|p| p.exists()) else {
5803 let msg = "PDF report was not generated for this run. \
5804 Re-run the analysis with PDF output enabled."
5805 .to_string();
5806 let html = ErrorTemplate {
5807 message: msg,
5808 last_report_url: Some(format!("/runs/html/{run_id}")),
5809 last_report_label: Some("View HTML Report".to_string()),
5810 run_id: Some(run_id.to_string()),
5811 error_code: Some(404),
5812 csp_nonce: csp_nonce.to_string(),
5813 version: env!("CARGO_PKG_VERSION"),
5814 }
5815 .render()
5816 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
5817 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5818 };
5819 let pdf_filename = build_pdf_filename(report_title, run_id);
5820 let pdf_dest = output_dir.join(&pdf_filename);
5821 if !pdf_dest.exists() {
5822 {
5824 let mut map = state.artifacts.lock().await;
5825 if let Some(entry) = map.get_mut(run_id) {
5826 entry.pdf_path = Some(pdf_dest.clone());
5827 }
5828 }
5829 {
5830 let mut reg = state.registry.lock().await;
5831 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
5832 e.pdf_path = Some(pdf_dest.clone());
5833 }
5834 let _ = reg.save(&state.registry_path);
5835 }
5836 spawn_native_pdf_background(
5837 json_src,
5838 pdf_dest.clone(),
5839 run_id.to_string(),
5840 state.artifacts.clone(),
5841 );
5842 }
5843 Ok(pdf_dest)
5844}
5845
5846fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
5848 let html = format!(
5849 "<!doctype html><html lang=\"en\"><head>\
5850 <meta charset=utf-8>\
5851 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
5852 <meta http-equiv=\"refresh\" content=\"5\">\
5853 <title>OxideSLOC | Generating PDF\u{2026}</title>\
5854 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
5855 <style nonce=\"{csp_nonce}\">\
5856 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
5857 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
5858 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
5859 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
5860 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
5861 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
5862 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
5863 background:var(--bg);color:var(--text);}}\
5864 .top-nav{{position:sticky;top:0;z-index:30;\
5865 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
5866 border-bottom:1px solid rgba(255,255,255,0.12);\
5867 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
5868 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
5869 min-height:56px;display:flex;align-items:center;gap:14px;}}\
5870 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
5871 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
5872 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
5873 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
5874 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
5875 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
5876 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
5877 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
5878 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
5879 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
5880 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
5881 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
5882 justify-content:center;min-height:38px;border-radius:999px;\
5883 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
5884 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
5885 .theme-toggle .icon-sun{{display:none;}}\
5886 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
5887 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
5888 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
5889 display:flex;align-items:center;justify-content:center;\
5890 min-height:calc(100vh - 56px);}}\
5891 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
5892 .panel{{background:var(--surface);border:1px solid var(--line);\
5893 border-radius:var(--radius);box-shadow:var(--shadow);\
5894 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
5895 .spin-ring{{width:56px;height:56px;border-radius:50%;\
5896 border:5px solid var(--line);border-top-color:var(--oxide-2);\
5897 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
5898 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
5899 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
5900 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
5901 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
5902 min-height:42px;padding:0 20px;border-radius:14px;\
5903 border:1px solid var(--line-strong);text-decoration:none;\
5904 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
5905 .back-link:hover{{background:var(--line);}}\
5906 </style></head>\
5907 <body>\
5908 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
5909 <a class=\"brand\" href=\"/\">\
5910 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
5911 <div class=\"brand-copy\">\
5912 <div class=\"brand-title\">OxideSLOC</div>\
5913 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5914 </div>\
5915 </a>\
5916 <div class=\"nav-right\">\
5917 <a class=\"nav-pill\" href=\"/\">Home</a>\
5918 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
5919 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
5920 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5921 <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>\
5922 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5923 <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>\
5924 </button>\
5925 </div>\
5926 </div></div>\
5927 <div class=\"page\"><div class=\"panel\">\
5928 <div class=\"spin-ring\"></div>\
5929 <h1>Generating PDF\u{2026}</h1>\
5930 <p>The PDF is being generated from the scan results.<br>\
5931 This page refreshes automatically \u{2014} usually a few seconds.</p>\
5932 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5933 </div></div>\
5934 <script nonce=\"{csp_nonce}\">\
5935 (function(){{\
5936 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5937 if(s===\"dark\")b.classList.add(\"dark-theme\");\
5938 var t=document.getElementById(\"theme-toggle\");\
5939 if(t)t.addEventListener(\"click\",function(){{\
5940 var d=b.classList.toggle(\"dark-theme\");\
5941 localStorage.setItem(k,d?\"dark\":\"light\");\
5942 }});\
5943 }})();\
5944 </script>\
5945 </body></html>"
5946 );
5947 Html(html).into_response()
5948}
5949
5950fn render_error_artifact_html(
5952 message: String,
5953 last_report_url: Option<String>,
5954 last_report_label: Option<String>,
5955 run_id: Option<String>,
5956 error_code: Option<u16>,
5957 csp_nonce: &str,
5958) -> String {
5959 ErrorTemplate {
5960 message,
5961 last_report_url,
5962 last_report_label,
5963 run_id,
5964 error_code,
5965 csp_nonce: csp_nonce.to_owned(),
5966 version: env!("CARGO_PKG_VERSION"),
5967 }
5968 .render()
5969 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
5970}
5971
5972fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
5974 fs::read(path).map_or_else(
5975 |_| StatusCode::NOT_FOUND.into_response(),
5976 |bytes| {
5977 let filename = path.file_name().map_or_else(
5978 || fallback_filename.to_string(),
5979 |n| n.to_string_lossy().into_owned(),
5980 );
5981 (
5982 [
5983 (header::CONTENT_TYPE, content_type.to_string()),
5984 (
5985 header::CONTENT_DISPOSITION,
5986 format!("attachment; filename=\"{filename}\""),
5987 ),
5988 ],
5989 bytes,
5990 )
5991 .into_response()
5992 },
5993 )
5994}
5995
5996fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
5997 let Some(path) = csv_path else {
5998 let html = render_error_artifact_html(
5999 "CSV report was not generated for this run, or was not recorded in \
6000 the scan registry."
6001 .to_string(),
6002 Some(format!("/runs/html/{run_id}")),
6003 Some("View HTML Report".to_string()),
6004 Some(run_id.to_string()),
6005 Some(404),
6006 csp_nonce,
6007 );
6008 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6009 };
6010 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
6011}
6012
6013fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6014 let Some(path) = xlsx_path else {
6015 let html = render_error_artifact_html(
6016 "Excel report was not generated for this run, or was not recorded in \
6017 the scan registry."
6018 .to_string(),
6019 Some(format!("/runs/html/{run_id}")),
6020 Some("View HTML Report".to_string()),
6021 Some(run_id.to_string()),
6022 Some(404),
6023 csp_nonce,
6024 );
6025 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6026 };
6027 serve_binary_download(
6028 &path,
6029 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6030 "report.xlsx",
6031 )
6032}
6033
6034fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6035 let path = artifact_set
6036 .scan_config_path
6037 .as_deref()
6038 .map(std::path::Path::to_path_buf)
6039 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6040 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6041 fs::read(&path).map_or_else(
6042 |_| StatusCode::NOT_FOUND.into_response(),
6043 |bytes| {
6044 (
6045 [
6046 (
6047 header::CONTENT_TYPE,
6048 "application/json; charset=utf-8".to_string(),
6049 ),
6050 (
6051 header::CONTENT_DISPOSITION,
6052 "attachment; filename=\"scan-config.json\"".to_string(),
6053 ),
6054 ],
6055 bytes,
6056 )
6057 .into_response()
6058 },
6059 )
6060}
6061
6062async fn serve_submodule_pdf_arm(
6067 artifact: &str,
6068 artifact_set: RunArtifacts,
6069 wants_download: bool,
6070 run_id: &str,
6071 csp_nonce: &str,
6072) -> Response {
6073 let base = artifact.trim_end_matches("_pdf");
6075 let sub_dir = artifact_set.output_dir.join("submodules");
6076 let pdf_path = sub_dir.join(format!("{base}.pdf"));
6077
6078 if !pdf_path.exists() {
6079 let derived_safe = base.trim_start_matches("sub_");
6081 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
6082 let parent_run = read_json(jp).ok()?;
6083 let sub = parent_run
6084 .submodule_summaries
6085 .iter()
6086 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
6087 .clone();
6088 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
6089 Some((parent_run, sub, parent_path))
6090 });
6091
6092 if let Some((parent_run, sub, parent_path)) = rebuilt {
6093 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
6094 let pp = pdf_path.clone();
6095 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
6096 }
6097 }
6098
6099 if !pdf_path.exists() {
6100 let html = render_error_artifact_html(
6101 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
6102 enabled."
6103 .to_string(),
6104 Some("/view-reports".to_string()),
6105 Some("View Reports".to_string()),
6106 Some(run_id.to_string()),
6107 Some(404),
6108 csp_nonce,
6109 );
6110 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6111 }
6112
6113 serve_pdf_artifact(
6114 &pdf_path,
6115 &artifact_set.report_title,
6116 run_id,
6117 wants_download,
6118 csp_nonce,
6119 )
6120}
6121
6122fn serve_submodule_arm(
6123 artifact: &str,
6124 artifact_set: &RunArtifacts,
6125 wants_download: bool,
6126 csp_nonce: &str,
6127 run_id: &str,
6128 server_mode: bool,
6129) -> Response {
6130 if artifact.len() > 128
6131 || !artifact
6132 .chars()
6133 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6134 {
6135 return StatusCode::BAD_REQUEST.into_response();
6136 }
6137 let filename = format!("{artifact}.html");
6138 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6140 let path = if new_layout.exists() {
6141 new_layout
6142 } else {
6143 artifact_set.output_dir.join(&filename)
6144 };
6145 if !path.exists() {
6146 let html = render_error_artifact_html(
6147 format!(
6148 "Sub-report '{artifact}' was not found in the run directory.\n\
6149 Re-run the analysis with 'Detect and separate git submodules' \
6150 and HTML output enabled."
6151 ),
6152 Some("/view-reports".to_string()),
6153 Some("View Reports".to_string()),
6154 Some(run_id.to_string()),
6155 Some(404),
6156 csp_nonce,
6157 );
6158 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6159 }
6160 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6161}
6162
6163async fn serve_pdf_arm(
6164 state: &AppState,
6165 artifact_set: RunArtifacts,
6166 wants_download: bool,
6167 run_id: &str,
6168 csp_nonce: &str,
6169) -> Response {
6170 let report_title = artifact_set.report_title.clone();
6171 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6172 let stale_html_name = artifact_set
6173 .html_path
6174 .as_deref()
6175 .and_then(|p| p.file_name())
6176 .map(|n| n.to_string_lossy().into_owned());
6177 let path = match resolve_or_queue_pdf(
6178 state,
6179 artifact_set.pdf_path,
6180 artifact_set.json_path.clone(),
6181 artifact_set.output_dir.clone(),
6182 run_id,
6183 &report_title,
6184 csp_nonce,
6185 )
6186 .await
6187 {
6188 Ok(p) => p,
6189 Err(r) => return r,
6190 };
6191 if !path.exists() {
6192 if had_pdf_in_registry {
6196 if let Some(expected_filename) = stale_html_name {
6197 let html = LocateFileTemplate {
6198 run_id: run_id.to_string(),
6199 artifact_type: "pdf".to_string(),
6200 expected_filename,
6201 server_mode: state.server_mode,
6202 csp_nonce: csp_nonce.to_string(),
6203 version: env!("CARGO_PKG_VERSION"),
6204 }
6205 .render()
6206 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6207 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6208 }
6209 }
6210 return pdf_generating_response(run_id, csp_nonce);
6211 }
6212 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6213}
6214
6215async fn artifact_handler(
6216 State(state): State<AppState>,
6217 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6218 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6219 Query(query): Query<ArtifactQuery>,
6220) -> Response {
6221 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6222 Ok(a) => a,
6223 Err(r) => return r,
6224 };
6225
6226 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6227
6228 match artifact.as_str() {
6229 "html" => {
6230 let Some(path) = artifact_set.html_path else {
6231 return StatusCode::NOT_FOUND.into_response();
6232 };
6233 serve_html_artifact(
6234 &path,
6235 wants_download,
6236 &csp_nonce,
6237 &run_id,
6238 state.server_mode,
6239 )
6240 }
6241 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6242 "json" => {
6243 let Some(path) = artifact_set.json_path else {
6244 let html = render_error_artifact_html(
6245 "JSON result was not generated for this run, or was not recorded in \
6246 the scan registry. Re-run the analysis with JSON output enabled."
6247 .to_string(),
6248 Some("/view-reports".to_string()),
6249 Some("View Reports".to_string()),
6250 Some(run_id.clone()),
6251 Some(404),
6252 &csp_nonce,
6253 );
6254 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6255 };
6256 serve_json_artifact(&path, wants_download, &csp_nonce)
6257 }
6258 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6259 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6260 "scan-config" => serve_scan_config_arm(&artifact_set),
6261 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
6262 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
6263 .await
6264 }
6265 _ if artifact.starts_with("sub_") => serve_submodule_arm(
6266 &artifact,
6267 &artifact_set,
6268 wants_download,
6269 &csp_nonce,
6270 &run_id,
6271 state.server_mode,
6272 ),
6273 _ => StatusCode::NOT_FOUND.into_response(),
6274 }
6275}
6276
6277struct SubmoduleLinkRow {
6280 name: String,
6281 url: String,
6282}
6283
6284struct HistoryEntryRow {
6285 run_id: String,
6286 run_id_short: String,
6287 timestamp: String,
6288 timestamp_utc_ms: i64,
6289 project_label: String,
6290 project_path: String,
6291 files_analyzed: u64,
6292 files_skipped: u64,
6293 code_lines: u64,
6294 comment_lines: u64,
6295 blank_lines: u64,
6296 git_branch: String,
6297 git_commit: String,
6298 has_html: bool,
6299 has_json: bool,
6300 has_pdf: bool,
6301 submodule_links: Vec<SubmoduleLinkRow>,
6302 submodule_names_csv: String,
6304}
6305
6306fn nth_weekday_of_month(
6308 year: i32,
6309 month: u32,
6310 weekday: chrono::Weekday,
6311 n: u32,
6312) -> chrono::NaiveDate {
6313 use chrono::Datelike;
6314 let mut count = 0u32;
6315 let mut day = 1u32;
6316 loop {
6317 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6318 if d.weekday() == weekday {
6319 count += 1;
6320 if count == n {
6321 return d;
6322 }
6323 }
6324 day += 1;
6325 }
6326}
6327
6328fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6332 use chrono::{Datelike, TimeZone};
6333 let year = dt.year();
6334 let dst_start = chrono::Utc.from_utc_datetime(
6335 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6336 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6337 );
6338 let dst_end = chrono::Utc.from_utc_datetime(
6339 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6340 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6341 );
6342 dt >= dst_start && dt < dst_end
6343}
6344
6345fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6346 if is_pacific_dst(dt) {
6347 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6348 .format("%Y-%m-%d %H:%M PDT")
6349 .to_string()
6350 } else {
6351 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6352 .format("%Y-%m-%d %H:%M PST")
6353 .to_string()
6354 }
6355}
6356
6357fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6359 let (offset, tz) = if is_pacific_dst(dt) {
6360 (
6361 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6362 "PDT",
6363 )
6364 } else {
6365 (
6366 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6367 "PST",
6368 )
6369 };
6370 format!(
6371 "{} {tz}",
6372 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6373 )
6374}
6375
6376fn fmt_git_date(iso: &str) -> Option<String> {
6377 chrono::DateTime::parse_from_rfc3339(iso)
6378 .ok()
6379 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6380}
6381
6382fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6383 reg.entries
6384 .iter()
6385 .map(|e| {
6386 let submodule_links = {
6387 let mut links: Vec<SubmoduleLinkRow> = vec![];
6388 let sub_dir = e
6389 .html_path
6390 .as_ref()
6391 .and_then(|p| p.parent())
6392 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6393 if let Some(dir) = sub_dir {
6394 if let Ok(rd) = std::fs::read_dir(dir) {
6395 for entry_res in rd.flatten() {
6396 let fname = entry_res.file_name();
6397 let fname_str = fname.to_string_lossy();
6398 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6399 let stem = &fname_str[..fname_str.len() - 5];
6400 let display = stem[4..].replace('-', " ");
6401 links.push(SubmoduleLinkRow {
6402 name: display,
6403 url: format!("/runs/{stem}/{}", e.run_id),
6404 });
6405 }
6406 }
6407 }
6408 }
6409 links.sort_by(|a, b| a.name.cmp(&b.name));
6410 links
6411 };
6412 let submodule_names_csv = submodule_links
6413 .iter()
6414 .map(|l| l.name.as_str())
6415 .collect::<Vec<_>>()
6416 .join(",");
6417 HistoryEntryRow {
6418 run_id: e.run_id.clone(),
6419 run_id_short: e
6420 .run_id
6421 .split('-')
6422 .next_back()
6423 .unwrap_or(&e.run_id)
6424 .chars()
6425 .take(7)
6426 .collect(),
6427 timestamp: fmt_la_time(e.timestamp_utc),
6428 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6429 project_label: e.project_label.clone(),
6430 project_path: e
6431 .input_roots
6432 .first()
6433 .map(|s| sanitize_path_str(s))
6434 .unwrap_or_default(),
6435 files_analyzed: e.summary.files_analyzed,
6436 files_skipped: e.summary.files_skipped,
6437 code_lines: e.summary.code_lines,
6438 comment_lines: e.summary.comment_lines,
6439 blank_lines: e.summary.blank_lines,
6440 git_branch: e.git_branch.clone().unwrap_or_default(),
6441 git_commit: e.git_commit.clone().unwrap_or_default(),
6442 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6443 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6444 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6445 submodule_links,
6446 submodule_names_csv,
6447 }
6448 })
6449 .collect()
6450}
6451
6452#[derive(Deserialize, Default)]
6453struct HistoryQuery {
6454 linked: Option<String>,
6455 error: Option<String>,
6456}
6457
6458async fn history_handler(
6459 State(state): State<AppState>,
6460 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6461 Query(query): Query<HistoryQuery>,
6462) -> impl IntoResponse {
6463 auto_scan_watched_dirs(&state).await;
6465 let watched_dirs: Vec<String> = {
6466 let wd = state.watched_dirs.lock().await;
6467 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6468 };
6469 let mut entries = {
6470 let reg = state.registry.lock().await;
6471 make_history_rows(®)
6472 };
6473 entries.retain(|e| e.has_html);
6474 let total_scans = entries.len();
6475 let linked_count = query
6476 .linked
6477 .as_deref()
6478 .and_then(|s| s.parse::<usize>().ok())
6479 .unwrap_or(0);
6480 let browse_error = query.error.filter(|s| !s.is_empty());
6481 let template = HistoryTemplate {
6482 version: env!("CARGO_PKG_VERSION"),
6483 entries,
6484 total_scans,
6485 linked_count,
6486 browse_error,
6487 watched_dirs,
6488 csp_nonce,
6489 server_mode: state.server_mode,
6490 };
6491 Html(
6492 template
6493 .render()
6494 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6495 )
6496 .into_response()
6497}
6498
6499async fn compare_select_handler(
6500 State(state): State<AppState>,
6501 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6502) -> impl IntoResponse {
6503 auto_scan_watched_dirs(&state).await;
6504 let watched_dirs: Vec<String> = {
6505 let wd = state.watched_dirs.lock().await;
6506 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6507 };
6508 let mut entries = {
6509 let reg = state.registry.lock().await;
6510 make_history_rows(®)
6511 };
6512 entries.retain(|e| e.has_json);
6513 let total_scans = entries.len();
6514 let template = CompareSelectTemplate {
6515 version: env!("CARGO_PKG_VERSION"),
6516 entries,
6517 total_scans,
6518 watched_dirs,
6519 csp_nonce,
6520 server_mode: state.server_mode,
6521 };
6522 Html(
6523 template
6524 .render()
6525 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6526 )
6527 .into_response()
6528}
6529
6530#[derive(Deserialize, Default)]
6533struct CompareQuery {
6534 a: Option<String>,
6535 b: Option<String>,
6536 sub: Option<String>,
6538 scope: Option<String>,
6540}
6541
6542struct CompareFileDeltaRow {
6543 relative_path: String,
6544 language: String,
6545 status: String,
6546 baseline_code: i64,
6547 current_code: i64,
6548 code_delta_str: String,
6549 code_delta_class: String,
6550 comment_delta_str: String,
6551 comment_delta_class: String,
6552 total_delta_str: String,
6553 total_delta_class: String,
6554}
6555
6556fn recompute_summary_from_records(run: &mut AnalysisRun) {
6559 let mut totals = SummaryTotals::default();
6560 for r in &run.per_file_records {
6561 if r.language.is_some() {
6562 totals.files_analyzed += 1;
6563 }
6564 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
6565 totals.code_lines += r.effective_counts.code_lines;
6566 totals.comment_lines += r.effective_counts.comment_lines;
6567 totals.blank_lines += r.effective_counts.blank_lines;
6568 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
6569 totals.functions += r.raw_line_categories.functions;
6570 totals.classes += r.raw_line_categories.classes;
6571 totals.variables += r.raw_line_categories.variables;
6572 totals.imports += r.raw_line_categories.imports;
6573 totals.test_count += r.raw_line_categories.test_count;
6574 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
6575 totals.test_suite_count += r.raw_line_categories.test_suite_count;
6576 if let Some(cov) = &r.coverage {
6577 totals.coverage_lines_found += u64::from(cov.lines_found);
6578 totals.coverage_lines_hit += u64::from(cov.lines_hit);
6579 totals.coverage_functions_found += u64::from(cov.functions_found);
6580 totals.coverage_functions_hit += u64::from(cov.functions_hit);
6581 totals.coverage_branches_found += u64::from(cov.branches_found);
6582 totals.coverage_branches_hit += u64::from(cov.branches_hit);
6583 }
6584 }
6585 totals.files_considered = totals.files_analyzed;
6586 run.summary_totals = totals;
6587}
6588
6589fn fmt_delta(n: i64) -> String {
6590 if n > 0 {
6591 format!("+{n}")
6592 } else {
6593 format!("{n}")
6594 }
6595}
6596
6597fn delta_class(n: i64) -> &'static str {
6598 use std::cmp::Ordering;
6599 match n.cmp(&0) {
6600 Ordering::Greater => "pos",
6601 Ordering::Less => "neg",
6602 Ordering::Equal => "zero",
6603 }
6604}
6605
6606#[allow(clippy::cast_precision_loss)]
6608fn fmt_pct(delta: i64, baseline: u64) -> String {
6609 if baseline == 0 {
6610 return "—".to_string();
6611 }
6612 #[allow(clippy::cast_precision_loss)]
6613 let pct = (delta as f64 / baseline as f64) * 100.0;
6614 if pct > 0.049 {
6615 format!("+{pct:.1}%")
6616 } else if pct < -0.049 {
6617 format!("{pct:.1}%")
6618 } else {
6619 "±0%".to_string()
6620 }
6621}
6622
6623fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6625 prev.map_or_else(
6626 || ("—".to_string(), "na"),
6627 |p| {
6628 #[allow(clippy::cast_possible_wrap)]
6629 let d = curr as i64 - p as i64;
6630 (fmt_delta(d), delta_class(d))
6631 },
6632 )
6633}
6634
6635#[allow(clippy::result_large_err)] fn load_scan_for_compare(
6637 json_path: &std::path::Path,
6638 scan_label: &str,
6639 run_id: &str,
6640 server_mode: bool,
6641 compare_url: &str,
6642 csp_nonce: &str,
6643) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6644 match read_json(json_path) {
6645 Ok(r) => Ok(r),
6646 Err(e) => {
6647 if server_mode {
6648 let html = ErrorTemplate {
6649 message: format!(
6650 "Could not load {scan_label} scan data. The scan output folder may have \
6651 been moved, renamed, or deleted. Re-running the analysis will create \
6652 fresh comparison data."
6653 ),
6654 last_report_url: Some("/compare-scans".to_string()),
6655 last_report_label: Some("Compare Scans".to_string()),
6656 run_id: Some(run_id.to_owned()),
6657 error_code: Some(404),
6658 csp_nonce: csp_nonce.to_owned(),
6659 version: env!("CARGO_PKG_VERSION"),
6660 }
6661 .render()
6662 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6663 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6664 }
6665 let msg = format!(
6666 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6667 json_path.display()
6668 );
6669 let folder_hint = json_path
6670 .parent()
6671 .map(|p| p.display().to_string())
6672 .unwrap_or_default();
6673 Err(missing_scan_relocate_response(
6674 &msg,
6675 run_id,
6676 &folder_hint,
6677 compare_url,
6678 false,
6679 csp_nonce,
6680 ))
6681 }
6682 }
6683}
6684
6685struct ChurnStats {
6686 new_scope: bool,
6687 scope_flag: bool,
6688 churn_rate_str: String,
6689 churn_rate_class: String,
6690}
6691
6692fn compute_churn_stats(
6693 baseline_code: u64,
6694 current_code: u64,
6695 lines_added: i64,
6696 lines_removed: i64,
6697) -> ChurnStats {
6698 let new_scope = baseline_code == 0 && current_code > 0;
6699 #[allow(clippy::cast_precision_loss)]
6700 let churn_pct = if baseline_code > 0 {
6701 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
6702 } else {
6703 0.0
6704 };
6705 #[allow(clippy::cast_precision_loss)]
6706 let scope_flag =
6707 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
6708 let churn_rate_str = if new_scope {
6709 "New".to_string()
6710 } else if baseline_code > 0 {
6711 format!("{churn_pct:.1}%")
6712 } else {
6713 "—".to_string()
6714 };
6715 let churn_rate_class = if new_scope || churn_pct > 20.0 {
6716 "high".to_string()
6717 } else if churn_pct > 5.0 {
6718 "med".to_string()
6719 } else {
6720 "low".to_string()
6721 };
6722 ChurnStats {
6723 new_scope,
6724 scope_flag,
6725 churn_rate_str,
6726 churn_rate_class,
6727 }
6728}
6729
6730fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
6734 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
6735 if !has_data {
6736 return String::new();
6737 }
6738 let base_str = s
6739 .baseline_coverage_line_pct
6740 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
6741 let curr_str = s
6742 .current_coverage_line_pct
6743 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
6744 let (delta_str, cls) = match s.coverage_line_pct_delta {
6745 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
6746 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
6747 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
6748 None => ("\u{2014}".into(), "zero"),
6749 };
6750 format!(
6751 r#"<div class="delta-card">
6752 <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo. Positive delta = more lines instrumented and hit. Only shown when at least one scan has coverage data.</div>
6753 <div class="delta-card-label">Line coverage</div>
6754 <div class="delta-card-from">Before: {base_str}</div>
6755 <div class="delta-card-to">{curr_str}</div>
6756 <span class="delta-card-change {cls}">{delta_str}</span>
6757 </div>"#
6758 )
6759}
6760
6761#[allow(clippy::too_many_lines)]
6762async fn compare_handler(
6763 State(state): State<AppState>,
6764 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6765 Query(query): Query<CompareQuery>,
6766) -> impl IntoResponse {
6767 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
6770 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
6771 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
6772 };
6773
6774 let (maybe_a, maybe_b) = {
6775 let reg = state.registry.lock().await;
6776 (
6777 reg.find_by_run_id(&run_id_a).cloned(),
6778 reg.find_by_run_id(&run_id_b).cloned(),
6779 )
6780 };
6781
6782 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
6783 let html = ErrorTemplate {
6784 message: "One or both run IDs were not found in scan history. \
6785 The runs may have been deleted or the registry may have been reset."
6786 .to_string(),
6787 last_report_url: Some("/compare-scans".to_string()),
6788 last_report_label: Some("Compare Scans".to_string()),
6789 run_id: None,
6790 error_code: None,
6791 csp_nonce: csp_nonce.clone(),
6792 version: env!("CARGO_PKG_VERSION"),
6793 }
6794 .render()
6795 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
6796 return Html(html).into_response();
6797 };
6798
6799 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
6801 (entry_a, entry_b)
6802 } else {
6803 (entry_b, entry_a)
6804 };
6805
6806 if baseline_entry.run_id != run_id_a {
6810 let canonical = format!(
6811 "/compare?a={}&b={}",
6812 baseline_entry.run_id, current_entry.run_id
6813 );
6814 return axum::response::Redirect::to(&canonical).into_response();
6815 }
6816
6817 let (Some(base_json), Some(curr_json)) = (
6818 baseline_entry.json_path.as_ref(),
6819 current_entry.json_path.as_ref(),
6820 ) else {
6821 let html = ErrorTemplate {
6822 message: "Full comparison requires JSON scan data, which was not saved for one or \
6823 both of these runs. JSON is now always saved for new scans — re-run the \
6824 affected projects to enable comparisons."
6825 .to_string(),
6826 last_report_url: Some("/compare-scans".to_string()),
6827 last_report_label: Some("Compare Scans".to_string()),
6828 run_id: None,
6829 error_code: None,
6830 csp_nonce: csp_nonce.clone(),
6831 version: env!("CARGO_PKG_VERSION"),
6832 }
6833 .render()
6834 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
6835 return Html(html).into_response();
6836 };
6837
6838 let compare_url = format!(
6839 "/compare?a={}&b={}",
6840 baseline_entry.run_id, current_entry.run_id
6841 );
6842
6843 let baseline_run = match load_scan_for_compare(
6844 base_json,
6845 "baseline",
6846 &baseline_entry.run_id,
6847 state.server_mode,
6848 &compare_url,
6849 &csp_nonce,
6850 ) {
6851 Ok(r) => r,
6852 Err(resp) => return resp,
6853 };
6854 let current_run = match load_scan_for_compare(
6855 curr_json,
6856 "current",
6857 ¤t_entry.run_id,
6858 state.server_mode,
6859 &compare_url,
6860 &csp_nonce,
6861 ) {
6862 Ok(r) => r,
6863 Err(resp) => return resp,
6864 };
6865
6866 let active_submodule = query.sub.clone();
6867 let super_scope_active = query.scope.as_deref() == Some("super");
6868
6869 let submodule_options = baseline_run
6870 .submodule_summaries
6871 .iter()
6872 .chain(current_run.submodule_summaries.iter())
6873 .map(|s| s.name.clone())
6874 .collect::<std::collections::BTreeSet<_>>()
6875 .into_iter()
6876 .collect::<Vec<_>>();
6877 let has_any_submodule_data = !submodule_options.is_empty();
6878
6879 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
6881 let mut b = baseline_run;
6882 let mut c = current_run;
6883 b.per_file_records
6884 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6885 c.per_file_records
6886 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6887 recompute_summary_from_records(&mut b);
6888 recompute_summary_from_records(&mut c);
6889 (b, c)
6890 } else if super_scope_active {
6891 let mut b = baseline_run;
6892 let mut c = current_run;
6893 b.per_file_records.retain(|f| f.submodule.is_none());
6894 c.per_file_records.retain(|f| f.submodule.is_none());
6895 recompute_summary_from_records(&mut b);
6896 recompute_summary_from_records(&mut c);
6897 (b, c)
6898 } else {
6899 (baseline_run, current_run)
6900 };
6901
6902 let comparison = compute_delta(&effective_baseline, &effective_current);
6903
6904 let file_rows: Vec<CompareFileDeltaRow> = comparison
6905 .file_deltas
6906 .iter()
6907 .map(|d| CompareFileDeltaRow {
6908 relative_path: d.relative_path.clone(),
6909 language: d.language.clone().unwrap_or_else(|| "—".into()),
6910 status: match d.status {
6911 FileChangeStatus::Added => "added".into(),
6912 FileChangeStatus::Removed => "removed".into(),
6913 FileChangeStatus::Modified => "modified".into(),
6914 FileChangeStatus::Unchanged => "unchanged".into(),
6915 },
6916 baseline_code: d.baseline_code,
6917 current_code: d.current_code,
6918 code_delta_str: fmt_delta(d.code_delta),
6919 code_delta_class: delta_class(d.code_delta).into(),
6920 comment_delta_str: fmt_delta(d.comment_delta),
6921 comment_delta_class: delta_class(d.comment_delta).into(),
6922 total_delta_str: fmt_delta(d.total_delta),
6923 total_delta_class: delta_class(d.total_delta).into(),
6924 })
6925 .collect();
6926
6927 let project_path = baseline_entry
6928 .input_roots
6929 .first()
6930 .map(|s| sanitize_path_str(s))
6931 .unwrap_or_default();
6932 let lines_added = sum_added_code_lines(&comparison);
6933 let lines_removed = sum_removed_code_lines(&comparison);
6934 let churn = compute_churn_stats(
6935 comparison.summary.baseline_code,
6936 comparison.summary.current_code,
6937 lines_added,
6938 lines_removed,
6939 );
6940 let s = &comparison.summary;
6941 let template = CompareTemplate {
6942 version: env!("CARGO_PKG_VERSION"),
6943 project_label: baseline_entry.project_label.clone(),
6944 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
6945 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
6946 baseline_run_id: baseline_entry.run_id.clone(),
6947 current_run_id: current_entry.run_id.clone(),
6948 baseline_run_id_short: baseline_entry
6949 .run_id
6950 .split('-')
6951 .next_back()
6952 .unwrap_or(&baseline_entry.run_id)
6953 .chars()
6954 .take(7)
6955 .collect(),
6956 current_run_id_short: current_entry
6957 .run_id
6958 .split('-')
6959 .next_back()
6960 .unwrap_or(¤t_entry.run_id)
6961 .chars()
6962 .take(7)
6963 .collect(),
6964 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
6965 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
6966 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
6967 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
6968 project_path: project_path.clone(),
6969 baseline_code: s.baseline_code,
6970 current_code: s.current_code,
6971 code_lines_delta_str: fmt_delta(s.code_lines_delta),
6972 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
6973 baseline_files: s.baseline_files,
6974 current_files: s.current_files,
6975 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
6976 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
6977 baseline_comments: s.baseline_comments,
6978 current_comments: s.current_comments,
6979 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
6980 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
6981 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
6982 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
6983 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
6984 code_lines_added: lines_added,
6985 code_lines_removed: lines_removed,
6986 new_scope: churn.new_scope,
6987 churn_rate_str: churn.churn_rate_str,
6988 churn_rate_class: churn.churn_rate_class,
6989 scope_flag: churn.scope_flag,
6990 files_added: comparison.files_added,
6991 files_removed: comparison.files_removed,
6992 files_modified: comparison.files_modified,
6993 files_unchanged: comparison.files_unchanged,
6994 file_rows,
6995 baseline_git_author: baseline_entry.git_author.clone(),
6996 current_git_author: current_entry.git_author.clone(),
6997 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
6998 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
6999 baseline_git_tags: baseline_entry.git_tags.clone(),
7000 current_git_tags: current_entry.git_tags.clone(),
7001 baseline_git_commit_date: baseline_entry
7002 .git_commit_date
7003 .as_deref()
7004 .and_then(fmt_git_date),
7005 current_git_commit_date: current_entry
7006 .git_commit_date
7007 .as_deref()
7008 .and_then(fmt_git_date),
7009 project_name: project_path
7010 .rsplit(['/', '\\'])
7011 .find(|s| !s.is_empty())
7012 .unwrap_or(&project_path)
7013 .to_string(),
7014 submodule_options,
7015 has_any_submodule_data,
7016 active_submodule,
7017 super_scope_active,
7018 csp_nonce,
7019 coverage_delta_card: build_coverage_delta_card(s),
7020 };
7021
7022 Html(
7023 template
7024 .render()
7025 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7026 )
7027 .into_response()
7028}
7029
7030fn format_number(n: u64) -> String {
7038 let s = n.to_string();
7039 let mut out = String::with_capacity(s.len() + s.len() / 3);
7040 let len = s.len();
7041 for (i, c) in s.chars().enumerate() {
7042 if i > 0 && (len - i).is_multiple_of(3) {
7043 out.push(',');
7044 }
7045 out.push(c);
7046 }
7047 out
7048}
7049
7050const fn badge_char_width(c: char) -> f64 {
7051 match c {
7052 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
7053 'm' | 'w' => 9.0,
7054 ' ' => 4.0,
7055 _ => 6.5,
7056 }
7057}
7058
7059#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7060fn badge_text_px(text: &str) -> u32 {
7061 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
7062}
7063
7064fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
7065 let lw = badge_text_px(label) + 20;
7066 let rw = badge_text_px(value) + 20;
7067 let total = lw + rw;
7068 let lx = lw / 2;
7069 let rx = lw + rw / 2;
7070 let le = escape_html(label);
7071 let ve = escape_html(value);
7072 let ce = escape_html(color);
7073 format!(
7074 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
7075 <rect width="{total}" height="20" fill="#555"/>
7076 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
7077 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
7078 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
7079 <text x="{lx}" y="13">{le}</text>
7080 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
7081 <text x="{rx}" y="13">{ve}</text>
7082 </g>
7083</svg>"##
7084 )
7085}
7086
7087#[derive(Deserialize)]
7088struct BadgeQuery {
7089 label: Option<String>,
7090 color: Option<String>,
7091}
7092
7093async fn badge_handler(
7094 State(state): State<AppState>,
7095 AxumPath(metric): AxumPath<String>,
7096 Query(query): Query<BadgeQuery>,
7097) -> Response {
7098 let entry = {
7099 let reg = state.registry.lock().await;
7100 reg.entries.first().cloned()
7101 };
7102
7103 let Some(entry) = entry else {
7104 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7105 return (
7106 [
7107 (header::CONTENT_TYPE, "image/svg+xml"),
7108 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7109 ],
7110 svg,
7111 )
7112 .into_response();
7113 };
7114
7115 let (default_label, value, default_color) = match metric.as_str() {
7116 "code-lines" => (
7117 "code lines",
7118 format_number(entry.summary.code_lines),
7119 "#4a78ee",
7120 ),
7121 "files" => (
7122 "files analyzed",
7123 format_number(entry.summary.files_analyzed),
7124 "#4a9862",
7125 ),
7126 "comment-lines" => (
7127 "comment lines",
7128 format_number(entry.summary.comment_lines),
7129 "#b35428",
7130 ),
7131 "blank-lines" => (
7132 "blank lines",
7133 format_number(entry.summary.blank_lines),
7134 "#7a5db0",
7135 ),
7136 _ => return StatusCode::NOT_FOUND.into_response(),
7137 };
7138
7139 let label = query.label.as_deref().unwrap_or(default_label);
7140 let color = query.color.as_deref().unwrap_or(default_color);
7141 let svg = render_badge_svg(label, &value, color);
7142
7143 (
7144 [
7145 (header::CONTENT_TYPE, "image/svg+xml"),
7146 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7147 ],
7148 svg,
7149 )
7150 .into_response()
7151}
7152
7153#[derive(Serialize)]
7161struct ApiCoverageBlock {
7162 lines_found: u64,
7163 lines_hit: u64,
7164 line_pct: f64,
7165 functions_found: u64,
7166 functions_hit: u64,
7167 function_pct: f64,
7168 branches_found: u64,
7169 branches_hit: u64,
7170 branch_pct: f64,
7171}
7172
7173#[derive(Serialize)]
7174struct ApiMetricsResponse {
7175 run_id: String,
7176 timestamp: String,
7177 project: String,
7178 summary: ApiSummaryPayload,
7179 languages: Vec<ApiLanguageRow>,
7180 #[serde(skip_serializing_if = "Option::is_none")]
7181 coverage: Option<ApiCoverageBlock>,
7182}
7183
7184#[derive(Serialize)]
7185struct ApiSummaryPayload {
7186 files_analyzed: u64,
7187 files_skipped: u64,
7188 code_lines: u64,
7189 comment_lines: u64,
7190 blank_lines: u64,
7191 total_physical_lines: u64,
7192 functions: u64,
7193 classes: u64,
7194 variables: u64,
7195 imports: u64,
7196}
7197
7198#[derive(Serialize)]
7199struct ApiLanguageRow {
7200 name: String,
7201 files: u64,
7202 code_lines: u64,
7203 comment_lines: u64,
7204 blank_lines: u64,
7205 functions: u64,
7206 classes: u64,
7207 variables: u64,
7208 imports: u64,
7209}
7210
7211async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7212 let entry = {
7213 let reg = state.registry.lock().await;
7214 reg.entries.first().cloned()
7215 };
7216 entry.map_or_else(
7217 || error::not_found("no scans recorded yet"),
7218 |e| build_metrics_response(&e),
7219 )
7220}
7221
7222async fn api_metrics_run_handler(
7223 State(state): State<AppState>,
7224 AxumPath(run_id): AxumPath<String>,
7225) -> Response {
7226 let entry = {
7227 let reg = state.registry.lock().await;
7228 reg.find_by_run_id(&run_id).cloned()
7229 };
7230 entry.map_or_else(
7231 || error::not_found("run not found"),
7232 |e| build_metrics_response(&e),
7233 )
7234}
7235
7236fn build_metrics_response(entry: &RegistryEntry) -> Response {
7237 let languages: Vec<ApiLanguageRow> = entry
7238 .json_path
7239 .as_ref()
7240 .and_then(|p| read_json(p).ok())
7241 .map(|run| {
7242 run.totals_by_language
7243 .iter()
7244 .map(|l| ApiLanguageRow {
7245 name: l.language.display_name().to_string(),
7246 files: l.files,
7247 code_lines: l.code_lines,
7248 comment_lines: l.comment_lines,
7249 blank_lines: l.blank_lines,
7250 functions: l.functions,
7251 classes: l.classes,
7252 variables: l.variables,
7253 imports: l.imports,
7254 })
7255 .collect()
7256 })
7257 .unwrap_or_default();
7258
7259 let s = &entry.summary;
7260 let coverage = if s.coverage_lines_found > 0 {
7261 let pct = |hit: u64, found: u64| -> f64 {
7262 if found == 0 {
7263 0.0
7264 } else {
7265 #[allow(clippy::cast_precision_loss)]
7266 let v = (hit as f64 / found as f64) * 100.0;
7267 (v * 10.0).round() / 10.0
7268 }
7269 };
7270 Some(ApiCoverageBlock {
7271 lines_found: s.coverage_lines_found,
7272 lines_hit: s.coverage_lines_hit,
7273 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7274 functions_found: s.coverage_functions_found,
7275 functions_hit: s.coverage_functions_hit,
7276 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7277 branches_found: s.coverage_branches_found,
7278 branches_hit: s.coverage_branches_hit,
7279 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7280 })
7281 } else {
7282 None
7283 };
7284 Json(ApiMetricsResponse {
7285 run_id: entry.run_id.clone(),
7286 timestamp: entry.timestamp_utc.to_rfc3339(),
7287 project: entry.project_label.clone(),
7288 summary: ApiSummaryPayload {
7289 files_analyzed: s.files_analyzed,
7290 files_skipped: s.files_skipped,
7291 code_lines: s.code_lines,
7292 comment_lines: s.comment_lines,
7293 blank_lines: s.blank_lines,
7294 total_physical_lines: s.total_physical_lines,
7295 functions: s.functions,
7296 classes: s.classes,
7297 variables: s.variables,
7298 imports: s.imports,
7299 },
7300 languages,
7301 coverage,
7302 })
7303 .into_response()
7304}
7305
7306#[derive(Deserialize)]
7313struct ProjectHistoryQuery {
7314 path: Option<String>,
7315}
7316
7317#[derive(Serialize)]
7318struct ProjectHistoryResponse {
7319 scan_count: usize,
7320 last_scan_id: Option<String>,
7321 last_scan_timestamp: Option<String>,
7322 last_scan_code_lines: Option<u64>,
7323 last_git_branch: Option<String>,
7324 last_git_commit: Option<String>,
7325}
7326
7327fn entry_matches_project(
7330 entry: &RegistryEntry,
7331 root_str: &str,
7332 upload_root: &str,
7333 upload_name_suffix: Option<&str>,
7334) -> bool {
7335 if entry.input_roots.iter().any(|r| r == root_str) {
7336 return true;
7337 }
7338 if let Some(suffix) = upload_name_suffix {
7339 return entry
7340 .input_roots
7341 .iter()
7342 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7343 }
7344 false
7345}
7346
7347async fn project_history_handler(
7348 State(state): State<AppState>,
7349 Query(query): Query<ProjectHistoryQuery>,
7350) -> Response {
7351 let path = query.path.unwrap_or_default();
7352 let resolved = resolve_input_path(&path);
7353 let root_str = resolved.to_string_lossy().replace('\\', "/");
7354
7355 let upload_root = std::env::temp_dir()
7360 .join("oxide-sloc-uploads")
7361 .to_string_lossy()
7362 .replace('\\', "/");
7363 let upload_name_suffix: Option<String> =
7364 if state.server_mode && root_str.starts_with(&upload_root) {
7365 resolved
7366 .file_name()
7367 .and_then(|n| n.to_str())
7368 .map(|name| format!("/{name}"))
7369 } else {
7370 None
7371 };
7372 let suffix_ref = upload_name_suffix.as_deref();
7373
7374 let entries: Vec<_> = {
7375 let reg = state.registry.lock().await;
7376 reg.entries
7377 .iter()
7378 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7379 .cloned()
7380 .collect()
7381 };
7382 let scan_count = entries.len();
7383 let last = entries.first();
7384 let last_scan_id = last.map(|e| e.run_id.clone());
7385 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7386 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7387 let last_git_branch = last.and_then(|e| e.git_branch.clone());
7388 let last_git_commit = last.and_then(|e| e.git_commit.clone());
7389
7390 Json(ProjectHistoryResponse {
7391 scan_count,
7392 last_scan_id,
7393 last_scan_timestamp,
7394 last_scan_code_lines,
7395 last_git_branch,
7396 last_git_commit,
7397 })
7398 .into_response()
7399}
7400
7401#[derive(Deserialize)]
7408struct MetricsHistoryQuery {
7409 root: Option<String>,
7410 limit: Option<usize>,
7411 submodule: Option<String>,
7414}
7415
7416#[derive(Serialize)]
7417struct MetricsSubmoduleLink {
7418 name: String,
7419 url: String,
7420}
7421
7422#[derive(Serialize)]
7423struct MetricsHistoryEntry {
7424 run_id: String,
7425 run_id_short: String,
7426 timestamp: String,
7427 commit: Option<String>,
7428 branch: Option<String>,
7429 tags: Vec<String>,
7430 nearest_tag: Option<String>,
7431 code_lines: u64,
7432 comment_lines: u64,
7433 blank_lines: u64,
7434 physical_lines: u64,
7435 files_analyzed: u64,
7436 files_skipped: u64,
7437 test_count: u64,
7438 project_label: String,
7439 html_url: Option<String>,
7440 has_pdf: bool,
7441 submodule_links: Vec<MetricsSubmoduleLink>,
7442 #[serde(skip_serializing_if = "Option::is_none")]
7444 coverage_line_pct: Option<f64>,
7445}
7446
7447fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7448 let mut links: Vec<MetricsSubmoduleLink> = vec![];
7449 let sub_dir = e
7450 .html_path
7451 .as_ref()
7452 .and_then(|p| p.parent())
7453 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7454 let Some(dir) = sub_dir else { return links };
7455 let Ok(rd) = std::fs::read_dir(dir) else {
7456 return links;
7457 };
7458 for entry_res in rd.flatten() {
7459 let fname = entry_res.file_name();
7460 let fname_str = fname.to_string_lossy();
7461 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7462 let stem = &fname_str[..fname_str.len() - 5];
7463 let display = stem[4..].replace('-', " ");
7464 links.push(MetricsSubmoduleLink {
7465 name: display,
7466 url: format!("/runs/{stem}/{}", e.run_id),
7467 });
7468 }
7469 }
7470 links.sort_by(|a, b| a.name.cmp(&b.name));
7471 links
7472}
7473
7474fn apply_submodule_filter(
7475 base: MetricsHistoryEntry,
7476 filter: &str,
7477 e: &sloc_core::history::RegistryEntry,
7478) -> Option<MetricsHistoryEntry> {
7479 let json_path = e.json_path.as_ref()?;
7480 let json_str = std::fs::read_to_string(json_path).ok()?;
7481 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7482 let sub = run
7483 .submodule_summaries
7484 .iter()
7485 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7486 let safe = sanitize_project_label(&sub.name);
7487 let artifact_key = format!("sub_{safe}");
7488 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7489 || base.html_url.clone(),
7490 |run_dir| {
7491 let sub_path = run_dir.join(format!("{artifact_key}.html"));
7492 if sub_path.exists() {
7493 Some(format!("/runs/{artifact_key}/{}", e.run_id))
7494 } else {
7495 base.html_url.clone()
7496 }
7497 },
7498 );
7499
7500 let sub_files: Vec<_> = run
7503 .per_file_records
7504 .iter()
7505 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
7506 .collect();
7507 let test_count: u64 = sub_files
7508 .iter()
7509 .map(|r| r.raw_line_categories.test_count)
7510 .sum();
7511 #[allow(clippy::cast_precision_loss)]
7512 let coverage_line_pct: Option<f64> = {
7513 let found: u64 = sub_files
7514 .iter()
7515 .filter_map(|r| r.coverage.as_ref())
7516 .map(|c| u64::from(c.lines_found))
7517 .sum();
7518 let hit: u64 = sub_files
7519 .iter()
7520 .filter_map(|r| r.coverage.as_ref())
7521 .map(|c| u64::from(c.lines_hit))
7522 .sum();
7523 if found > 0 {
7524 let pct = (hit as f64 / found as f64) * 100.0;
7525 Some((pct * 10.0).round() / 10.0)
7526 } else {
7527 None
7528 }
7529 };
7530
7531 Some(MetricsHistoryEntry {
7532 code_lines: sub.code_lines,
7533 comment_lines: sub.comment_lines,
7534 blank_lines: sub.blank_lines,
7535 physical_lines: sub.total_physical_lines,
7536 files_analyzed: sub.files_analyzed,
7537 files_skipped: 0,
7538 test_count,
7539 html_url: sub_html_url,
7540 has_pdf: false,
7541 submodule_links: vec![],
7542 coverage_line_pct,
7543 ..base
7544 })
7545}
7546
7547#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
7549 State(state): State<AppState>,
7550 Query(query): Query<MetricsHistoryQuery>,
7551) -> Response {
7552 let limit = query.limit.unwrap_or(50).min(500);
7553 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7554
7555 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7556 let reg = state.registry.lock().await;
7557 reg.entries
7558 .iter()
7559 .filter(|e| {
7560 query.root.as_ref().is_none_or(|root| {
7561 let resolved = resolve_input_path(root);
7562 let root_str = resolved.to_string_lossy().replace('\\', "/");
7563 e.input_roots.iter().any(|r| r == &root_str)
7564 })
7565 })
7566 .take(limit)
7567 .cloned()
7568 .collect()
7569 };
7570
7571 let entries: Vec<MetricsHistoryEntry> = candidate_entries
7572 .into_iter()
7573 .filter_map(|e| {
7574 let tags = e
7575 .git_tags
7576 .as_deref()
7577 .map(|s| {
7578 s.split(',')
7579 .map(|t| t.trim().to_string())
7580 .filter(|t| !t.is_empty())
7581 .collect()
7582 })
7583 .unwrap_or_default();
7584 let html_url = e
7585 .html_path
7586 .as_ref()
7587 .filter(|p| p.exists())
7588 .map(|_| format!("/runs/html/{}", e.run_id));
7589 let nearest_tag = e.git_nearest_tag.clone();
7590 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7591 let run_id_short: String = e
7592 .run_id
7593 .split('-')
7594 .next_back()
7595 .unwrap_or(&e.run_id)
7596 .chars()
7597 .take(7)
7598 .collect();
7599 let submodule_links = build_entry_submodule_links(&e);
7600 #[allow(clippy::cast_precision_loss)]
7601 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7602 let pct = (e.summary.coverage_lines_hit as f64
7603 / e.summary.coverage_lines_found as f64)
7604 * 100.0;
7605 Some((pct * 10.0).round() / 10.0)
7606 } else {
7607 None
7608 };
7609 let base = MetricsHistoryEntry {
7610 run_id: e.run_id.clone(),
7611 run_id_short,
7612 timestamp: e.timestamp_utc.to_rfc3339(),
7613 commit: e.git_commit.clone(),
7614 branch: e.git_branch.clone(),
7615 tags,
7616 nearest_tag,
7617 code_lines: e.summary.code_lines,
7618 comment_lines: e.summary.comment_lines,
7619 blank_lines: e.summary.blank_lines,
7620 physical_lines: e.summary.total_physical_lines,
7621 files_analyzed: e.summary.files_analyzed,
7622 files_skipped: e.summary.files_skipped,
7623 test_count: e.summary.test_count,
7624 project_label: e.project_label.clone(),
7625 html_url,
7626 has_pdf,
7627 submodule_links,
7628 coverage_line_pct,
7629 };
7630 if let Some(ref filter) = submodule_filter {
7631 apply_submodule_filter(base, filter, &e)
7632 } else {
7633 Some(base)
7634 }
7635 })
7636 .collect();
7637
7638 Json(entries).into_response()
7639}
7640
7641#[derive(Deserialize)]
7645struct MetricsSubmodulesQuery {
7646 root: Option<String>,
7647}
7648
7649#[derive(Serialize)]
7650struct SubmoduleEntry {
7651 name: String,
7652 relative_path: String,
7653}
7654
7655async fn api_metrics_submodules_handler(
7656 State(state): State<AppState>,
7657 Query(query): Query<MetricsSubmodulesQuery>,
7658) -> Response {
7659 let json_paths: Vec<std::path::PathBuf> = {
7660 let reg = state.registry.lock().await;
7661 reg.entries
7662 .iter()
7663 .filter(|e| {
7664 query.root.as_ref().is_none_or(|root| {
7665 let resolved = resolve_input_path(root);
7666 let root_str = resolved.to_string_lossy().replace('\\', "/");
7667 e.input_roots.iter().any(|r| r == &root_str)
7668 })
7669 })
7670 .filter_map(|e| e.json_path.clone())
7671 .collect()
7672 };
7673
7674 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
7675 let mut result: Vec<SubmoduleEntry> = Vec::new();
7676
7677 for path in &json_paths {
7678 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
7679 continue;
7680 };
7681 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
7682 continue;
7683 };
7684 for sub in &run.submodule_summaries {
7685 if seen.insert(sub.name.clone()) {
7686 result.push(SubmoduleEntry {
7687 name: sub.name.clone(),
7688 relative_path: sub.relative_path.clone(),
7689 });
7690 }
7691 }
7692 }
7693
7694 result.sort_by(|a, b| a.name.cmp(&b.name));
7695 Json(result).into_response()
7696}
7697
7698#[derive(Deserialize)]
7707struct IngestQuery {
7708 label: Option<String>,
7709}
7710
7711#[derive(Serialize)]
7712struct IngestResponse {
7713 run_id: String,
7714 view_url: String,
7715}
7716
7717async fn api_ingest_handler(
7718 State(state): State<AppState>,
7719 Query(q): Query<IngestQuery>,
7720 Json(run): Json<sloc_core::AnalysisRun>,
7721) -> Response {
7722 let label = q.label.unwrap_or_else(|| {
7723 run.input_roots
7724 .first()
7725 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
7726 });
7727
7728 let label_for_task = label.clone();
7729 let result = tokio::task::spawn_blocking(move || {
7730 let html = render_html(&run)?;
7731 let run_id = run.tool.run_id.clone();
7732 let run_id_safe = run_id.len() <= 128
7733 && !run_id.is_empty()
7734 && run_id
7735 .chars()
7736 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
7737 if !run_id_safe {
7738 anyhow::bail!(
7739 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
7740 );
7741 }
7742 let project_label = sanitize_project_label(&label_for_task);
7743 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
7744 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
7745 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
7746 _ => project_label,
7747 };
7748 let (artifacts, _pending_pdf) = persist_run_artifacts(
7749 &run,
7750 &html,
7751 &output_dir,
7752 &label_for_task,
7753 &file_stem,
7754 RunResultContext::default(),
7755 )?;
7756 Ok::<_, anyhow::Error>((run_id, artifacts, run))
7757 })
7758 .await;
7759
7760 match result {
7761 Ok(Ok((run_id, artifacts, run))) => {
7762 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
7763 (
7764 StatusCode::CREATED,
7765 Json(IngestResponse {
7766 view_url: format!("/view-reports?run_id={run_id}"),
7767 run_id,
7768 }),
7769 )
7770 .into_response()
7771 }
7772 Ok(Err(e)) => error::internal(&format!("{e:#}")),
7773 Err(e) => error::internal(&format!("{e}")),
7774 }
7775}
7776
7777#[allow(clippy::too_many_lines)] async fn trend_report_handler(
7785 State(state): State<AppState>,
7786 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7787) -> Response {
7788 auto_scan_watched_dirs(&state).await;
7789
7790 let watched_dirs_list: Vec<String> = {
7791 let wd = state.watched_dirs.lock().await;
7792 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7793 };
7794
7795 let roots: Vec<String> = {
7797 let reg = state.registry.lock().await;
7798 let mut seen = std::collections::BTreeSet::new();
7799 reg.entries
7800 .iter()
7801 .flat_map(|e| e.input_roots.iter().cloned())
7802 .filter(|r| seen.insert(r.clone()))
7803 .collect()
7804 };
7805
7806 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
7807 let nonce = &csp_nonce;
7808 let version = env!("CARGO_PKG_VERSION");
7809
7810 let watched_dirs_html: String = if state.server_mode {
7814 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()
7815 } else {
7816 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7817 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7818 .to_string()
7819 } else {
7820 watched_dirs_list
7821 .iter()
7822 .fold(String::new(), |mut s, d| {
7823 use std::fmt::Write as _;
7824 let escaped =
7825 d.replace('&', "&").replace('"', """).replace('<', "<");
7826 write!(
7827 s,
7828 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>"#
7829 ).expect("write to String is infallible");
7830 s
7831 })
7832 };
7833 format!(
7834 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>"#
7835 )
7836 };
7837
7838 let html = format!(
7839 r##"<!doctype html>
7840<html lang="en">
7841<head>
7842 <meta charset="utf-8" />
7843 <meta name="viewport" content="width=device-width, initial-scale=1" />
7844 <title>OxideSLOC | Trend Reports</title>
7845 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7846 <style nonce="{nonce}">
7847 :root {{
7848 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7849 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7850 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7851 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7852 --info-bg:#eef3ff; --info-text:#4467d8;
7853 }}
7854 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7855 *{{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;}}
7856 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7857 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7858 .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;}}
7859 @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));}}}}
7860 .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);}}
7861 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7862 .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));}}
7863 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7864 .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;}}
7865 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7866 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7867 @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; }} }}
7868 .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;}}
7869 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7870 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7871 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7872 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7873 .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;}}
7874 .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;}}
7875 .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;}}
7876 .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;}}
7877 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7878 .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);}}
7879 .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;}}
7880 .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;}}
7881 .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;}}
7882 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7883 .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;}}
7884 .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);}}
7885 .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;}}
7886 .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;}}
7887 .tz-select:focus{{border-color:var(--oxide);}}
7888 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
7889 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
7890 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7891 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7892 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7893 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
7894 .trend-title-block{{flex:1;min-width:0;}}
7895 .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;}}
7896 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
7897 .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;}}
7898 .chart-select:focus{{border-color:var(--accent);}}
7899 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7900 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7901 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
7902 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7903 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7904 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7905 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
7906 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7907 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7908 .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;}}
7909 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
7910 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
7911 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
7912 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7913 .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;}}
7914 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
7915 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
7916 .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);}}
7917 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
7918 .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;}}
7919 .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;}}
7920 .data-table tr:last-child td{{border-bottom:none;}}
7921 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
7922 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
7923 .table-wrap{{width:100%;overflow-x:auto;}}
7924 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
7925 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
7926 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
7927 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
7928 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
7929 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
7930 .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;}}
7931 .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;}}
7932 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
7933 .pagination-info{{font-size:13px;color:var(--muted);}}
7934 .pagination-btns{{display:flex;gap:6px;}}
7935 .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;}}
7936 .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;}}
7937 #scan-history-table col:nth-child(1){{width:155px;}}
7938 #scan-history-table col:nth-child(2){{width:240px;}}
7939 #scan-history-table col:nth-child(3){{width:82px;}}
7940 #scan-history-table col:nth-child(4){{width:82px;}}
7941 #scan-history-table col:nth-child(5){{width:90px;}}
7942 #scan-history-table col:nth-child(6){{width:90px;}}
7943 #scan-history-table col:nth-child(7){{width:88px;}}
7944 #scan-history-table col:nth-child(8){{width:150px;}}
7945 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
7946 .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;}}
7947 .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;}}
7948 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
7949 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
7950 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7951 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7952 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7953 .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;}}
7954 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7955 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7956 .watched-chip-rm:hover{{color:var(--oxide);}}
7957 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7958 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7959 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7960 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7961 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
7962 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
7963 a.run-link:hover{{text-decoration:underline;}}
7964 .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);}}
7965 .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);}}
7966 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
7967 .metric-num{{font-weight:700;color:var(--text);}}
7968 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
7969 .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;}}
7970 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
7971 .btn.primary:hover{{opacity:.9;}}
7972 .rpt-btn{{min-width:58px;justify-content:center;}}
7973 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
7974 .report-cell{{overflow:visible!important;white-space:normal!important;}}
7975 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
7976 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
7977 .submod-details summary::-webkit-details-marker{{display:none;}}
7978 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
7979 .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;}}
7980 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
7981 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
7982 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
7983 .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;}}
7984 .export-btn:hover{{background:var(--line);}}
7985 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
7986 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7987 .site-footer a{{color:var(--muted);}}
7988 .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;}}
7989 .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;}}
7990 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
7991 </style>
7992</head>
7993<body>
7994 <div class="background-watermarks" aria-hidden="true">
7995 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7996 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7997 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7998 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7999 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8000 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8001 </div>
8002 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8003 <div class="top-nav">
8004 <div class="top-nav-inner">
8005 <a class="brand" href="/">
8006 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8007 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
8008 </a>
8009 <div class="nav-right">
8010 <a class="nav-pill" href="/">Home</a>
8011 <div class="nav-dropdown">
8012 <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>
8013 <div class="nav-dropdown-menu">
8014 <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>
8015 </div>
8016 </div>
8017 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8018 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
8019 <div class="nav-dropdown">
8020 <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>
8021 <div class="nav-dropdown-menu">
8022 <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>
8023 </div>
8024 </div>
8025 <div class="server-status-wrap" id="server-status-wrap">
8026 <div class="nav-pill server-online-pill" id="server-status-pill">
8027 <span class="status-dot" id="status-dot"></span>
8028 <span id="server-status-label">Server</span>
8029 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8030 </div>
8031 <div class="server-status-tip">
8032 OxideSLOC is running — accessible on your network.
8033 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8034 </div>
8035 </div>
8036 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8037 <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>
8038 </button>
8039 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8040 <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>
8041 <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>
8042 </button>
8043 </div>
8044 </div>
8045 </div>
8046
8047 <div class="page">
8048 {watched_dirs_html}
8049 <div class="summary-strip" id="trend-stats"></div>
8050 <div class="panel">
8051 <div class="trend-header">
8052 <div class="trend-title-block">
8053 <h1>Trend Reports</h1>
8054 <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root, choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
8055 <span class="chart-hint-inline">
8056 <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>
8057 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
8058 </span>
8059 </div>
8060 <div class="chart-actions">
8061 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
8062 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
8063 Retention Policy
8064 </button>
8065 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
8066 <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>
8067 Clean up old runs
8068 </button>
8069 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
8070 <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>
8071 Export Excel
8072 </button>
8073 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
8074 <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>
8075 Export PNG
8076 </button>
8077 </div>
8078 </div>
8079
8080 <div class="controls-centered">
8081 <label>Project Root:
8082 <select class="chart-select" id="root-sel">
8083 <option value="">All projects</option>
8084 </select>
8085 </label>
8086 <label>Y Metric:
8087 <select class="chart-select" id="y-sel">
8088 <option value="code_lines">Code Lines</option>
8089 <option value="comment_lines">Comment Lines</option>
8090 <option value="blank_lines">Blank Lines</option>
8091 <option value="physical_lines">Physical Lines</option>
8092 <option value="files_analyzed">Files Analyzed</option>
8093 </select>
8094 </label>
8095 <label>X Axis:
8096 <select class="chart-select" id="x-sel">
8097 <option value="time">By Time</option>
8098 <option value="commit">By Commit</option>
8099 <option value="release">By Release</option>
8100 <option value="tag">Tagged Commits</option>
8101 </select>
8102 </label>
8103 <label id="submodule-label" style="display:none;">Submodule:
8104 <select class="chart-select" id="sub-sel">
8105 <option value="">All (project total)</option>
8106 </select>
8107 </label>
8108 <label>Chart Size:
8109 <select class="chart-select" id="scale-sel">
8110 <option value="0.75">Compact</option>
8111 <option value="1.2" selected>Normal</option>
8112 <option value="1.38">Large</option>
8113 </select>
8114 </label>
8115 </div>
8116
8117 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
8118 <div id="data-table-wrap" style="overflow-x:auto;"></div>
8119 </div>
8120 </div>
8121
8122 <script nonce="{nonce}">
8123 (function() {{
8124 // Theme persistence
8125 var b = document.body;
8126 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8127 var tgl = document.getElementById('theme-toggle');
8128 if (tgl) tgl.addEventListener('click', function() {{
8129 var d = b.classList.toggle('dark-theme');
8130 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8131 }});
8132
8133 // Watermark randomizer
8134 (function() {{
8135 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8136 if (!wms.length) return;
8137 var placed = [];
8138 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;}}
8139 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];}}
8140 var half=Math.floor(wms.length/2);
8141 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;}});
8142 }})();
8143
8144 // Code particles
8145 (function() {{
8146 var container = document.getElementById('code-particles');
8147 if (!container) return;
8148 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'];
8149 for (var i = 0; i < 38; i++) {{
8150 (function(idx) {{
8151 var el = document.createElement('span');
8152 el.className = 'code-particle';
8153 el.textContent = snippets[idx % snippets.length];
8154 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8155 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8156 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8157 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';
8158 container.appendChild(el);
8159 }})(i);
8160 }}
8161 }})();
8162
8163 // Watched folder picker
8164 (function() {{
8165 var btn = document.getElementById('add-watched-btn');
8166 if (!btn) return;
8167 btn.addEventListener('click', function() {{
8168 fetch('/pick-directory?kind=reports')
8169 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8170 .then(function(data) {{
8171 if (!data.cancelled && data.selected_path) {{
8172 var form = document.createElement('form');
8173 form.method = 'POST';
8174 form.action = '/watched-dirs/add';
8175 var ri = document.createElement('input');
8176 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8177 var fi = document.createElement('input');
8178 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8179 form.appendChild(ri); form.appendChild(fi);
8180 document.body.appendChild(form);
8181 form.submit();
8182 }}
8183 }})
8184 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8185 }});
8186 }})();
8187
8188 // Settings / color-scheme modal
8189 (function() {{
8190 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'}}];
8191 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);}});}}
8192 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8193 var btn=document.getElementById('settings-btn');if(!btn)return;
8194 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8195 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>';
8196 document.body.appendChild(m);
8197 var g=document.getElementById('scheme-grid');
8198 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);}});
8199 var cl=document.getElementById('settings-close');
8200 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);
8201 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');}});
8202 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8203 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8204 }})();
8205 }})();
8206
8207 var ROOTS = {roots_json};
8208 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8209 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
8210 var allData = [];
8211
8212 // Populate root selector
8213 var rootSel = document.getElementById('root-sel');
8214 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
8215
8216 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();}}
8217 function fmtFull(n){{return Number(n).toLocaleString();}}
8218 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
8219
8220 // Tooltip
8221 var tt = document.createElement('div');
8222 tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:9999;max-width:280px;color:var(--text);';
8223 document.body.appendChild(tt);
8224 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
8225 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';}}
8226 function hideTT(){{tt.style.display='none';}}
8227 window.addEventListener('blur',function(){{hideTT();}});
8228 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
8229
8230 function statExact(compact, full){{
8231 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
8232 }}
8233 function statVal(n){{
8234 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
8235 }}
8236
8237 function updateStats(data){{
8238 var statsEl=document.getElementById('trend-stats');
8239 if(!statsEl)return;
8240 if(!data||!data.length){{statsEl.innerHTML='';return;}}
8241 var yKey=document.getElementById('y-sel').value;
8242 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8243 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8244 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
8245 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
8246 var absDelta=Math.abs(delta);
8247 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
8248 var deltaExact=statExact(deltaCompact,deltaFull);
8249 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
8250 statsEl.innerHTML=
8251 '<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>'+
8252 '<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>'+
8253 '<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>'+
8254 '<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>';
8255 }}
8256
8257 var subSel = document.getElementById('sub-sel');
8258 var subLabel = document.getElementById('submodule-label');
8259
8260 function populateSubmodules(root){{
8261 if(!subSel||!subLabel)return;
8262 while(subSel.options.length>1)subSel.remove(1);
8263 subSel.value='';
8264 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
8265 fetch(url)
8266 .then(function(r){{return r.json();}})
8267 .then(function(subs){{
8268 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
8269 subs.forEach(function(s){{
8270 var o=document.createElement('option');
8271 o.value=s.name;
8272 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
8273 subSel.appendChild(o);
8274 }});
8275 subLabel.style.display='';
8276 }})
8277 .catch(function(){{subLabel.style.display='none';}});
8278 }}
8279
8280 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
8281
8282 function loadAndRender(){{
8283 var root = rootSel.value;
8284 var sub = subSel ? subSel.value : '';
8285 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
8286 document.getElementById('data-table-wrap').innerHTML='';
8287 var url = '/api/metrics/history?limit=100'
8288 + (root ? '&root='+encodeURIComponent(root) : '')
8289 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
8290 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
8291 allData = data;
8292 render(data);
8293 updateStats(data);
8294 }}).catch(function(){{
8295 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>';
8296 }});
8297 }}
8298
8299 function render(data){{
8300 var yKey = document.getElementById('y-sel').value;
8301 var xMode = document.getElementById('x-sel').value;
8302
8303 // Filter for tag/release mode
8304 var pts = data;
8305 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
8306
8307 // Sort oldest-first for the line chart
8308 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8309
8310 var wrap = document.getElementById('chart-wrap');
8311 if(!pts.length){{
8312 var emptyMsg = (xMode === 'tag')
8313 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
8314 : 'No scan data found for the selected filters.';
8315 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
8316 renderTable([]);
8317 return;
8318 }}
8319
8320 var scaleEl=document.getElementById('scale-sel');
8321 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
8322 var W=Math.round(900*sc),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
8323 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
8324
8325 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8326
8327 var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
8328 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>';
8329
8330 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
8331
8332 // Grid + Y axis ticks
8333 for(var ti=0;ti<=5;ti++){{
8334 var gy=PT+CH-Math.round(ti/5*CH);
8335 var gv=Math.round(ti/5*maxY);
8336 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
8337 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
8338 }}
8339
8340 // X axis labels (every N-th point to avoid crowding)
8341 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
8342 pts.forEach(function(d,i){{
8343 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8344 if(i%labelEvery===0||i===pts.length-1){{
8345 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)));
8346 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>';
8347 }}
8348 }});
8349
8350 // Axis label
8351 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
8352 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>';
8353 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>';
8354
8355 // Area fill + line path
8356 var pathD='';
8357 pts.forEach(function(d,i){{
8358 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8359 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8360 pathD+=(i===0?'M':'L')+x+','+y;
8361 }});
8362 if(pts.length>1){{
8363 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
8364 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
8365 }}
8366 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
8367
8368 // Data points (clickable) + permanent value labels
8369 var showLabels = pts.length <= 40;
8370 var labelEveryN = pts.length > 20 ? 2 : 1;
8371 pts.forEach(function(d,i){{
8372 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8373 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8374 var hasTags=d.tags&&d.tags.length>0;
8375 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
8376 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
8377 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+'"/>';
8378 if(showLabels && i%labelEveryN===0){{
8379 var lx=x, ly=y-r-5;
8380 svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmt(Number(d[yKey]))+'</text>';
8381 }}
8382 }});
8383
8384 svg+='</svg>';
8385 wrap.innerHTML=svg;
8386
8387 // Attach point tooltips
8388 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
8389 c.addEventListener('mouseover',function(e){{
8390 var d=pts[parseInt(this.dataset.idx)];
8391 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(''):'';
8392 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>':'';
8393 showTT(e,
8394 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
8395 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
8396 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
8397 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
8398 );
8399 this.setAttribute('r','8');
8400 }});
8401 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
8402 c.addEventListener('mousemove',moveTT);
8403 c.addEventListener('click',function(){{
8404 var d=pts[parseInt(this.dataset.idx)];
8405 if(d.html_url) window.open(d.html_url,'_blank');
8406 }});
8407 }});
8408
8409 renderTable(pts, yKey);
8410 }}
8411
8412 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
8413 var shProjFilter='', shBranchFilter='';
8414
8415 function fmtPST(isoStr){{
8416 if(!isoStr)return'';
8417 var d=new Date(isoStr);
8418 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
8419 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);}}
8420 function p(n){{return n<10?'0'+n:String(n);}}
8421 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++;}}}}
8422 var yr=d.getUTCFullYear();
8423 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
8424 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
8425 var isDST=d>=dstStart&&d<dstEnd;
8426 var off=isDST?-7*3600*1000:-8*3600*1000;
8427 var lbl=isDST?'PDT':'PST';
8428 var loc=new Date(d.getTime()+off);
8429 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
8430 }}
8431
8432 function getShRows(){{
8433 var proj=shProjFilter.toLowerCase().trim();
8434 var branch=shBranchFilter;
8435 return shData.filter(function(d){{
8436 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
8437 if(branch&&(d.branch||'')!==branch)return false;
8438 return true;
8439 }});
8440 }}
8441
8442 function renderShPage(){{
8443 var filtered=getShRows();
8444 if(shSortCol){{
8445 filtered.sort(function(a,b){{
8446 var va,vb;
8447 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
8448 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
8449 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
8450 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
8451 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
8452 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
8453 }});
8454 }}
8455 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
8456 shPage=Math.min(shPage,totalPages);
8457 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
8458 var visible=filtered.slice(start,end);
8459 var tbody=document.getElementById('sh-tbody');
8460 if(!tbody)return;
8461 tbody.innerHTML=visible.map(function(d){{
8462 var tsHtml=esc(fmtPST(d.timestamp));
8463 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>';
8464 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>';
8465 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
8466 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
8467 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
8468 var reportCell='';
8469 if(d.html_url){{
8470 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
8471 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>';}}
8472 reportCell+='</div>';
8473 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
8474 if(d.submodule_links&&d.submodule_links.length){{
8475 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
8476 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
8477 reportCell+='</div></details>';
8478 }}
8479 return '<tr>'
8480 +'<td>'+tsHtml+'</td>'
8481 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
8482 +'<td>'+runIdHtml+'</td>'
8483 +'<td>'+commitHtml+'</td>'
8484 +'<td>'+branchHtml+'</td>'
8485 +'<td>'+tags+'</td>'
8486 +'<td class="num">'+metricHtml+'</td>'
8487 +'<td class="report-cell">'+reportCell+'</td>'
8488 +'</tr>';
8489 }}).join('');
8490 var pgRange=document.getElementById('sh-pg-range');
8491 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
8492 var pgInfo=document.getElementById('sh-pg-info');
8493 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
8494 var pgBtns=document.getElementById('sh-pg-btns');
8495 if(pgBtns){{
8496 pgBtns.innerHTML='';
8497 function mkPgBtn(lbl,pg,active,disabled){{
8498 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
8499 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
8500 return b;
8501 }}
8502 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
8503 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
8504 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
8505 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
8506 }}
8507 }}
8508
8509 function wireTableBehavior(){{
8510 var pf=document.getElementById('sh-proj-filter');
8511 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
8512 var bf=document.getElementById('sh-branch-filter');
8513 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
8514 var rb=document.getElementById('sh-reset-btn');
8515 if(rb)rb.addEventListener('click',function(){{
8516 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
8517 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
8518 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
8519 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');}});
8520 renderShPage();
8521 }});
8522 var pps=document.getElementById('sh-per-page');
8523 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
8524 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
8525 ths.forEach(function(th){{
8526 th.addEventListener('click',function(e){{
8527 if(e.target.classList.contains('col-resize-handle'))return;
8528 var col=th.dataset.col;
8529 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
8530 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
8531 th.classList.add('sort-'+shSortOrder);
8532 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
8533 shPage=1;renderShPage();
8534 }});
8535 }});
8536 var table=document.getElementById('scan-history-table');
8537 if(!table)return;
8538 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
8539 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
8540 allThs.forEach(function(th,i){{
8541 var handle=th.querySelector('.col-resize-handle');
8542 if(!handle||!cols[i])return;
8543 var startX,startW;
8544 handle.addEventListener('mousedown',function(e){{
8545 e.stopPropagation();e.preventDefault();
8546 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
8547 handle.classList.add('dragging');
8548 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
8549 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
8550 document.addEventListener('mousemove',onMove);
8551 document.addEventListener('mouseup',onUp);
8552 }});
8553 }});
8554 }}
8555
8556 function renderTable(pts, yKey){{
8557 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
8558 var wrap=document.getElementById('data-table-wrap');
8559 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
8560 var yLabel=Y_LABELS[yKey]||yKey||'';
8561 shData=pts.slice().reverse();
8562 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
8563 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
8564 var branches={{}};
8565 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
8566 var branchOpts='<option value="">All branches</option>';
8567 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
8568 wrap.innerHTML=
8569 '<div class="chart-section-header">SCAN HISTORY</div>'+
8570 '<div class="filter-row">'+
8571 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
8572 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
8573 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
8574 '</div>'+
8575 '<div class="table-wrap">'+
8576 '<table id="scan-history-table" class="data-table">'+
8577 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
8578 '<thead><tr id="sh-thead">'+
8579 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8580 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8581 '<th>Run ID<div class="col-resize-handle"></div></th>'+
8582 '<th>Commit<div class="col-resize-handle"></div></th>'+
8583 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8584 '<th>Tags<div class="col-resize-handle"></div></th>'+
8585 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8586 '<th>Report<div class="col-resize-handle"></div></th>'+
8587 '</tr></thead>'+
8588 '<tbody id="sh-tbody"></tbody>'+
8589 '</table>'+
8590 '</div>'+
8591 '<div class="pagination">'+
8592 '<span class="pagination-info" id="sh-pg-info"></span>'+
8593 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
8594 '<div style="display:flex;align-items:center;gap:8px;">'+
8595 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
8596 '<select class="filter-select" id="sh-per-page">'+
8597 '<option value="10">10 per page</option>'+
8598 '<option value="25" selected>25 per page</option>'+
8599 '<option value="50">50 per page</option>'+
8600 '<option value="100">100 per page</option>'+
8601 '</select>'+
8602 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
8603 '</div>'+
8604 '</div>';
8605 wireTableBehavior();
8606 renderShPage();
8607 }}
8608
8609 function exportXLSX(){{
8610 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
8611 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
8612 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
8613 var s1R=sorted.map(function(d){{
8614 return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',d.commit||'',d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||''];
8615 }});
8616 var pm={{}};
8617 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
8618 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'];
8619 var s2R=Object.keys(pm).map(function(p){{
8620 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8621 var lat=sc[sc.length-1],fst=sc[0];
8622 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
8623 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);
8624 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];
8625 }});
8626 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
8627 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
8628 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
8629 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
8630 }}
8631
8632 function buildXLSX(sheets,chartRows,chartRows2){{
8633 function s2b(s){{return new TextEncoder().encode(s);}}
8634 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
8635 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;}}
8636 function crc32(d){{
8637 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;}}}}
8638 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
8639 }}
8640 function buildSheet(hdr,rows,drawRid,withCtrl){{
8641 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
8642 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
8643 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
8644 x+='<row r="1">';
8645 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
8646 if(withCtrl){{x+='<c r="M1" t="inlineStr" s="1"><is><t>↓ Metric Selector</t></is></c><c r="N1" t="inlineStr"><is><t>Code Lines</t></is></c>';}}
8647 x+='</row>';
8648 rows.forEach(function(row,ri){{
8649 var rn=ri+2;
8650 x+='<row r="'+rn+'">';
8651 row.forEach(function(cell,ci){{
8652 var addr=col2l(ci+1)+rn;
8653 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
8654 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
8655 }});
8656 if(withCtrl){{x+='<c r="M'+rn+'"><f>CHOOSE(MATCH($N$1,{{"Code Lines","Comment Lines","Blank Lines","Physical Lines"}},0),F'+rn+',G'+rn+',H'+rn+',I'+rn+')</f><v>'+Number(row[5])+'</v></c>';}}
8657 x+='</row>';
8658 }});
8659 x+='</sheetData>';
8660 if(withCtrl){{x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="N1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines"</formula1></dataValidation></dataValidations>';}}
8661 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
8662 return x+'</worksheet>';
8663 }}
8664 function buildChartXML(rows){{
8665 var sn="'Scan History'";
8666 var nr=rows.length,er=nr+1;
8667 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'}}];
8668 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8669 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">';
8670 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8671 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8672 sd.forEach(function(s,i){{
8673 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8674 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>';
8675 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8676 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>';
8677 var dlp=(i===2)?'b':'t';
8678 x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="'+dlp+'"/></c:dLbls>';
8679 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8680 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8681 x+='</c:strCache></c:strRef></c:cat>';
8682 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+'"/>';
8683 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8684 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8685 }});
8686 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
8687 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>';
8688 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>';
8689 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8690 return x;
8691 }}
8692 function buildChartXML2(rows){{
8693 var sn="'By Project'";
8694 var nr=rows.length,er=nr+1;
8695 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'}}];
8696 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8697 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">';
8698 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8699 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8700 sd.forEach(function(s,i){{
8701 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8702 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>';
8703 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8704 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>';
8705 var dlp=(i===2)?'b':'t';
8706 x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="'+dlp+'"/></c:dLbls>';
8707 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8708 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8709 x+='</c:strCache></c:strRef></c:cat>';
8710 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+'"/>';
8711 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8712 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8713 }});
8714 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
8715 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>';
8716 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>';
8717 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8718 return x;
8719 }}
8720 function buildChartXML3(rows){{
8721 var sn="'Scan History'";
8722 var nr=rows.length,er=nr+1;
8723 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8724 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">';
8725 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
8726 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8727 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
8728 x+='<c:tx><c:strRef><c:f>'+sn+'!$N$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>';
8729 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
8730 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>';
8731 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>';
8732 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8733 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8734 x+='</c:strCache></c:strRef></c:cat>';
8735 x+='<c:val><c:numRef><c:f>'+sn+'!$M$2:$M$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
8736 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
8737 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8738 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
8739 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>';
8740 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>';
8741 x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Focus View — change N1 to switch metric</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8742 return x;
8743 }}
8744 var hasChart=!!(chartRows&&chartRows.length);
8745 var nr=hasChart?chartRows.length:0;
8746 var hasChart2=!!(chartRows2&&chartRows2.length);
8747 var nr2=hasChart2?chartRows2.length:0;
8748 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>';
8749 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"/>';
8750 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
8751 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"/>';}}
8752 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"/>';}}
8753 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
8754 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>';
8755 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
8756 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"/>';}});
8757 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
8758 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>';
8759 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
8760 wbx+='</sheets></workbook>';
8761 var files=[
8762 {{name:'[Content_Types].xml',data:s2b(ct)}},
8763 {{name:'_rels/.rels',data:s2b(dotrels)}},
8764 {{name:'xl/workbook.xml',data:s2b(wbx)}},
8765 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
8766 {{name:'xl/styles.xml',data:s2b(styl)}}
8767 ];
8768 // Chart embedded directly in Scan History (sheet1); By Project is plain
8769 sheets.forEach(function(s,i){{
8770 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0)))}});
8771 }});
8772 if(hasChart){{
8773 var fromRow=nr+4,toRow=nr+24;
8774 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>')}});
8775 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8776 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">';
8777 drx+='<xdr:twoCellAnchor editAs="twoCell">';
8778 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>';
8779 drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
8780 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8781 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8782 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8783 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8784 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
8785 var focRow=toRow+2,focRowEnd=toRow+22;
8786 drx+='<xdr:twoCellAnchor editAs="twoCell">';
8787 drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
8788 drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRowEnd+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
8789 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8790 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8791 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8792 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
8793 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
8794 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
8795 files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
8796 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
8797 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
8798 }}
8799 if(hasChart2){{
8800 var fromRow2=nr2+4,toRow2=nr2+24;
8801 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>')}});
8802 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8803 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">';
8804 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
8805 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>';
8806 drx2+='<xdr:to><xdr:col>11</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
8807 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8808 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8809 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8810 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8811 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
8812 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
8813 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>')}});
8814 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
8815 }}
8816 var parts=[],offsets=[],total=0;
8817 files.forEach(function(f){{
8818 offsets.push(total);
8819 var nb=s2b(f.name),crc=crc32(f.data);
8820 var h=new DataView(new ArrayBuffer(30+nb.length));
8821 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
8822 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
8823 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
8824 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
8825 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
8826 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
8827 total+=30+nb.length+f.data.length;
8828 }});
8829 var cdStart=total;
8830 files.forEach(function(f,fi){{
8831 var nb=s2b(f.name),crc=crc32(f.data);
8832 var cd=new DataView(new ArrayBuffer(46+nb.length));
8833 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
8834 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
8835 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
8836 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
8837 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
8838 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
8839 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
8840 }});
8841 var cdSz=total-cdStart;
8842 var eocd=new DataView(new ArrayBuffer(22));
8843 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
8844 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
8845 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
8846 parts.push(new Uint8Array(eocd.buffer));
8847 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
8848 var out=new Uint8Array(sz);var off=0;
8849 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
8850 return out.buffer;
8851 }}
8852
8853 function exportPNG(){{
8854 var svgEl=document.querySelector('#chart-wrap svg');
8855 if(!svgEl){{alert('No chart to export yet.');return;}}
8856 var svgStr=new XMLSerializer().serializeToString(svgEl);
8857 var vb=svgEl.viewBox.baseVal,scale=2;
8858 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
8859 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
8860 var url=URL.createObjectURL(blob);
8861 var img=new Image();
8862 img.onload=function(){{
8863 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
8864 var ctx=canvas.getContext('2d');
8865 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
8866 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
8867 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
8868 URL.revokeObjectURL(url);
8869 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
8870 }};
8871 img.src=url;
8872 }}
8873
8874 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
8875 var el=document.getElementById(id);
8876 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
8877 }});
8878 rootSel.addEventListener('change',function(){{
8879 populateSubmodules(rootSel.value);
8880 loadAndRender();
8881 }});
8882 if(subSel)subSel.addEventListener('change',loadAndRender);
8883
8884 var xlsxBtn=document.getElementById('export-xlsx-btn');
8885 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
8886 var pngBtn=document.getElementById('export-png-btn');
8887 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
8888
8889 // ── Clean-up modal ───────────────────────────────────────────────────────
8890 (function(){{
8891 var triggerBtn=document.getElementById('cleanup-runs-btn');
8892 if(!triggerBtn)return;
8893 var modal=document.createElement('div');
8894 modal.style.cssText='display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;';
8895 modal.innerHTML='<div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:460px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">'
8896 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
8897 +'<p style="font-size:13px;color:var(--text);margin:0 0 14px;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
8898 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
8899 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
8900 +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:80px;padding:7px 10px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
8901 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
8902 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
8903 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
8904 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
8905 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
8906 +'</div></div>';
8907 document.body.appendChild(modal);
8908 triggerBtn.addEventListener('click',function(){{
8909 document.getElementById('cleanup-status').style.display='none';
8910 modal.style.display='flex';
8911 }});
8912 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
8913 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8914 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
8915 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
8916 var confirmBtn=this;
8917 confirmBtn.disabled=true;
8918 var status=document.getElementById('cleanup-status');
8919 status.style.display='block';
8920 status.style.background='#dbeafe';status.style.color='#1e40af';
8921 status.textContent='Deleting\u2026';
8922 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
8923 .then(function(resp){{
8924 return resp.json().then(function(d){{
8925 if(resp.ok){{
8926 status.style.background='#dcfce7';status.style.color='#166534';
8927 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
8928 setTimeout(function(){{window.location.reload();}},1500);
8929 }}else{{
8930 status.style.background='#fee2e2';status.style.color='#991b1b';
8931 status.textContent='Error: '+(d.error||'Unexpected error');
8932 confirmBtn.disabled=false;
8933 }}
8934 }});
8935 }})
8936 .catch(function(e){{
8937 status.style.background='#fee2e2';status.style.color='#991b1b';
8938 status.textContent='Network error: '+String(e);
8939 confirmBtn.disabled=false;
8940 }});
8941 }});
8942 }})();
8943
8944 // ── Retention policy panel ────────────────────────────────────────────────
8945 (function(){{
8946 var triggerBtn=document.getElementById('retention-policy-btn');
8947 if(!triggerBtn)return;
8948 var modal=document.createElement('div');
8949 modal.style.cssText='display:none;position:fixed;inset:0;z-index:9001;background:rgba(0,0,0,0.72);align-items:center;justify-content:center;';
8950 modal.innerHTML=''
8951 +'<div style="background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:36px 44px;max-width:580px;width:95%;box-shadow:0 24px 64px rgba(0,0,0,0.38);">'
8952 +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
8953 +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
8954 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
8955 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
8956 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
8957 +'</div>'
8958 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
8959 +'<div>'
8960 +'<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>'
8961 +'<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;">'
8962 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
8963 +'</div>'
8964 +'<div>'
8965 +'<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>'
8966 +'<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;">'
8967 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
8968 +'</div>'
8969 +'</div>'
8970 +'<div style="margin-bottom:20px;">'
8971 +'<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>'
8972 +'<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;">'
8973 +'<option value="1">Every hour</option>'
8974 +'<option value="6">Every 6 hours</option>'
8975 +'<option value="12">Every 12 hours</option>'
8976 +'<option value="24" selected>Every 24 hours</option>'
8977 +'<option value="48">Every 2 days</option>'
8978 +'<option value="72">Every 3 days</option>'
8979 +'<option value="168">Every week</option>'
8980 +'</select>'
8981 +'</div>'
8982 +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">—</div>'
8983 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
8984 +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
8985 +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
8986 +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
8987 +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
8988 +'</div>'
8989 +'</div>';
8990 document.body.appendChild(modal);
8991
8992 function rpShowStatus(msg,ok){{
8993 var s=document.getElementById('rp-status');
8994 s.style.display='block';
8995 s.style.background=ok?'#dcfce7':'#fee2e2';
8996 s.style.color=ok?'#166534':'#991b1b';
8997 s.textContent=msg;
8998 }}
8999 function fmtAgo(iso){{
9000 if(!iso)return'Never';
9001 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
9002 if(diff<60)return diff+'s ago';
9003 if(diff<3600)return Math.floor(diff/60)+'m ago';
9004 if(diff<86400)return Math.floor(diff/3600)+'h ago';
9005 return Math.floor(diff/86400)+'d ago';
9006 }}
9007 function loadPolicy(){{
9008 fetch('/api/cleanup-policy')
9009 .then(function(r){{return r.json();}})
9010 .then(function(d){{
9011 var p=d.policy;
9012 document.getElementById('rp-enabled').checked=p?p.enabled:false;
9013 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
9014 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
9015 var sel=document.getElementById('rp-interval');
9016 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;}}}}}}
9017 var lr=document.getElementById('rp-last-run');
9018 if(d.last_run_at){{
9019 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'):'');
9020 }}else{{
9021 lr.textContent='Auto-cleanup has not run yet.';
9022 }}
9023 }})
9024 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
9025 }}
9026
9027 triggerBtn.addEventListener('click',function(){{
9028 document.getElementById('rp-status').style.display='none';
9029 loadPolicy();
9030 modal.style.display='flex';
9031 }});
9032 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
9033 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
9034
9035 document.getElementById('rp-save-btn').addEventListener('click',function(){{
9036 var enabled=document.getElementById('rp-enabled').checked;
9037 var ageVal=document.getElementById('rp-max-age').value.trim();
9038 var countVal=document.getElementById('rp-max-count').value.trim();
9039 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
9040 if(enabled&&!ageVal&&!countVal){{
9041 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
9042 return;
9043 }}
9044 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
9045 var saveBtn=document.getElementById('rp-save-btn');
9046 saveBtn.disabled=true;
9047 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
9048 .then(function(r){{
9049 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
9050 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
9051 }})
9052 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
9053 .finally(function(){{saveBtn.disabled=false;}});
9054 }});
9055
9056 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
9057 var btn=this;
9058 btn.disabled=true;
9059 btn.textContent='Running\u2026';
9060 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
9061 .then(function(r){{return r.json();}})
9062 .then(function(d){{
9063 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
9064 loadPolicy();
9065 }})
9066 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
9067 .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
9068 }});
9069 }})();
9070
9071 populateSubmodules(rootSel.value);
9072 loadAndRender();
9073
9074 (function randomizeWatermarks() {{
9075 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9076 if (!wms.length) return;
9077 var placed = [];
9078 function tooClose(top, left) {{
9079 for (var i = 0; i < placed.length; i++) {{
9080 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
9081 if (dt < 16 && dl < 12) return true;
9082 }}
9083 return false;
9084 }}
9085 function pick(leftBand) {{
9086 for (var attempt = 0; attempt < 50; attempt++) {{
9087 var top = Math.random() * 88 + 2;
9088 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
9089 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
9090 }}
9091 var top = Math.random() * 88 + 2;
9092 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
9093 placed.push([top, left]); return [top, left];
9094 }}
9095 var half = Math.floor(wms.length / 2);
9096 wms.forEach(function (img, i) {{
9097 var pos = pick(i < half);
9098 var size = Math.floor(Math.random() * 100 + 120);
9099 var rot = (Math.random() * 360).toFixed(1);
9100 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
9101 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;
9102 }});
9103 }})();
9104 (function spawnCodeParticles() {{
9105 var container = document.getElementById('code-particles');
9106 if (!container) return;
9107 var snippets = [
9108 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
9109 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
9110 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
9111 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
9112 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
9113 ];
9114 var count = 38;
9115 for (var i = 0; i < count; i++) {{
9116 (function(idx) {{
9117 var el = document.createElement('span');
9118 el.className = 'code-particle';
9119 el.textContent = snippets[idx % snippets.length];
9120 var left = Math.random() * 94 + 2;
9121 var top = Math.random() * 88 + 6;
9122 var dur = (Math.random() * 10 + 9).toFixed(1);
9123 var delay = (Math.random() * 18).toFixed(1);
9124 var rot = (Math.random() * 26 - 13).toFixed(1);
9125 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9126 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
9127 container.appendChild(el);
9128 }})(i);
9129 }}
9130 }})();
9131 </script>
9132 <footer class="site-footer">
9133 local code analysis - metrics, history and reports
9134 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
9135 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9136 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9137 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9138 · <a href="/api-docs" rel="noopener">REST API</a>
9139 </footer>
9140 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
9141</body>
9142</html>"##,
9143 );
9144
9145 Html(html).into_response()
9146}
9147
9148fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9149 use std::collections::HashMap;
9150 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
9151 return vec![];
9152 }
9153 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9154 for rec in per_file_records {
9155 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9156 let e = totals.entry(lang.display_name().to_string()).or_default();
9157 e.0 += u64::from(cov.lines_found);
9158 e.1 += u64::from(cov.lines_hit);
9159 }
9160 }
9161 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
9163 .into_iter()
9164 .filter(|(_, (found, _))| *found > 0)
9165 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9166 .collect();
9167 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9168 pairs
9169 .iter()
9170 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
9171 .collect()
9172}
9173
9174fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
9175 let mut high = 0u64;
9176 let mut mid = 0u64;
9177 let mut low = 0u64;
9178 for rec in per_file_records {
9179 if let Some(cov) = &rec.coverage {
9180 if cov.lines_found == 0 {
9181 continue;
9182 }
9183 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
9184 if pct >= 80.0 {
9185 high += 1;
9186 } else if pct >= 50.0 {
9187 mid += 1;
9188 } else {
9189 low += 1;
9190 }
9191 }
9192 }
9193 (high, mid, low)
9194}
9195
9196fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9197 let mut arr: Vec<serde_json::Value> = per_file_records
9198 .iter()
9199 .filter_map(|rec| {
9200 rec.coverage.as_ref().map(|cov| {
9201 let line_pct = if cov.lines_found > 0 {
9202 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
9203 / 10.0
9204 } else {
9205 0.0
9206 };
9207 let fn_pct = if cov.functions_found > 0 {
9208 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
9209 .round()
9210 / 10.0
9211 } else {
9212 -1.0
9213 };
9214 serde_json::json!({
9215 "rel": rec.relative_path,
9216 "lang": rec.language.map_or("?", |l| l.display_name()),
9217 "line_pct": line_pct,
9218 "fn_pct": fn_pct,
9219 "lhit": cov.lines_hit,
9220 "lfound": cov.lines_found,
9221 "fhit": cov.functions_hit,
9222 "ffound": cov.functions_found,
9223 })
9224 })
9225 })
9226 .collect();
9227 arr.sort_by(|a, b| {
9228 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
9229 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
9230 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
9231 });
9232 arr
9233}
9234
9235#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
9237 let mut langs: Vec<&sloc_core::LanguageSummary> = run
9238 .totals_by_language
9239 .iter()
9240 .filter(|l| l.test_count > 0)
9241 .collect();
9242 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9243 let lang_tests: Vec<serde_json::Value> = langs
9244 .iter()
9245 .map(|l| {
9246 let d = if l.code_lines > 0 {
9247 l.test_count as f64 / l.code_lines as f64 * 1000.0
9248 } else {
9249 0.0
9250 };
9251 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9252 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9253 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9254 })
9255 .collect();
9256 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
9257 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9258 let t = &run.summary_totals;
9259 let total_tests = t.test_count;
9260 let density = if t.code_lines > 0 {
9261 total_tests as f64 / t.code_lines as f64 * 1000.0
9262 } else {
9263 0.0
9264 };
9265 let most_tested = langs.first().map_or_else(
9266 || "\u{2014}".to_string(),
9267 |l| l.language.display_name().to_string(),
9268 );
9269 let test_files: u64 = run
9270 .per_file_records
9271 .iter()
9272 .filter(|f| f.raw_line_categories.test_count > 0)
9273 .count() as u64;
9274 let cov_line = if t.coverage_lines_found > 0 {
9275 format!(
9276 "{:.1}",
9277 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
9278 )
9279 } else {
9280 "0".to_string()
9281 };
9282 let cov_fn = if t.coverage_functions_found > 0 {
9283 format!(
9284 "{:.1}",
9285 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
9286 )
9287 } else {
9288 "0".to_string()
9289 };
9290 let cov_branch = if t.coverage_branches_found > 0 {
9291 format!(
9292 "{:.1}",
9293 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
9294 )
9295 } else {
9296 "0".to_string()
9297 };
9298 let has_cov = !cov_arr.is_empty();
9299 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
9300 serde_json::json!({
9301 "totals": {
9302 "test_count": total_tests,
9303 "assertions": t.test_assertion_count,
9304 "suites": t.test_suite_count,
9305 "test_files": test_files,
9306 "total_files": t.files_analyzed,
9307 "density_str": format!("{density:.1}"),
9308 "most_tested": most_tested,
9309 "langs_with_tests": langs.len(),
9310 "cov_line": cov_line,
9311 "cov_fn": cov_fn,
9312 "cov_branch": cov_branch,
9313 },
9314 "lang_tests": lang_tests,
9315 "cov": cov_arr,
9316 "cov_tiers": {"high": high, "mid": mid, "low": low},
9317 "file_cov": file_cov_arr,
9318 "has_coverage": has_cov,
9319 "submodules": {},
9320 })
9321}
9322
9323#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
9325 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
9326 .language_summaries
9327 .iter()
9328 .filter(|l| l.test_count > 0)
9329 .collect();
9330 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9331 let lang_tests: Vec<serde_json::Value> = langs
9332 .iter()
9333 .map(|l| {
9334 let d = if l.code_lines > 0 {
9335 l.test_count as f64 / l.code_lines as f64 * 1000.0
9336 } else {
9337 0.0
9338 };
9339 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9340 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9341 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9342 })
9343 .collect();
9344 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
9345 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
9346 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
9347 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
9348 let density = if sub.code_lines > 0 {
9349 total_tests as f64 / sub.code_lines as f64 * 1000.0
9350 } else {
9351 0.0
9352 };
9353 let most_tested = langs.first().map_or_else(
9354 || "\u{2014}".to_string(),
9355 |l| l.language.display_name().to_string(),
9356 );
9357 serde_json::json!({
9358 "totals": {
9359 "test_count": total_tests,
9360 "assertions": total_assertions,
9361 "suites": total_suites,
9362 "test_files": test_files_approx,
9363 "total_files": sub.files_analyzed,
9364 "density_str": format!("{density:.1}"),
9365 "most_tested": most_tested,
9366 "langs_with_tests": langs.len(),
9367 "cov_line": "0",
9368 "cov_fn": "0",
9369 "cov_branch": "0",
9370 },
9371 "lang_tests": lang_tests,
9372 "cov": [],
9373 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
9374 "has_coverage": false,
9375 })
9376}
9377
9378fn compute_cov_json_str(run: &AnalysisRun) -> String {
9379 use std::collections::HashMap;
9380 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9381 for rec in &run.per_file_records {
9382 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9383 let e = totals.entry(lang.display_name().to_string()).or_default();
9384 e.0 += u64::from(cov.lines_found);
9385 e.1 += u64::from(cov.lines_hit);
9386 }
9387 }
9388 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
9390 .into_iter()
9391 .filter(|(_, (found, _))| *found > 0)
9392 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9393 .collect();
9394 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9395 let parts: Vec<String> = pairs
9396 .iter()
9397 .map(|(lang, pct)| {
9398 let name = lang.replace('"', "\\\"");
9399 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
9400 })
9401 .collect();
9402 format!("[{}]", parts.join(","))
9403}
9404
9405fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
9406 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9407 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
9408}
9409
9410fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
9411 let mut entry = build_test_scope_entry(run);
9412 if !run.submodule_summaries.is_empty() {
9413 let subs: serde_json::Map<String, serde_json::Value> = run
9414 .submodule_summaries
9415 .iter()
9416 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
9417 .collect();
9418 entry["submodules"] = serde_json::Value::Object(subs);
9419 }
9420 entry
9421}
9422
9423fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
9424 let name = l.language.display_name().replace('"', "\\\"");
9425 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
9427 l.test_count as f64 / l.code_lines as f64 * 1000.0
9428 } else {
9429 0.0
9430 };
9431 format!(
9432 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
9433 name = name,
9434 t = l.test_count,
9435 a = l.test_assertion_count,
9436 s = l.test_suite_count,
9437 c = l.code_lines,
9438 d = density,
9439 f = l.files,
9440 )
9441}
9442
9443fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
9444 let Some(r) = run else {
9445 return "[]".to_string();
9446 };
9447 let mut langs: Vec<&sloc_core::LanguageSummary> = r
9448 .totals_by_language
9449 .iter()
9450 .filter(|l| l.test_count > 0)
9451 .collect();
9452 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9453 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
9454 format!("[{}]", parts.join(","))
9455}
9456
9457async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
9459 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
9460 scope_map.insert(
9461 "__all__".to_string(),
9462 latest_run.map_or_else(
9463 || {
9464 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
9465 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
9466 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
9467 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
9468 "has_coverage":false,"submodules":{}})
9469 },
9470 build_test_scope_entry,
9471 ),
9472 );
9473 let all_roots: Vec<String> = {
9474 let reg = state.registry.lock().await;
9475 let mut seen = std::collections::BTreeSet::new();
9476 reg.entries
9477 .iter()
9478 .flat_map(|e| e.input_roots.iter().cloned())
9479 .filter(|r| seen.insert(r.clone()))
9480 .collect()
9481 };
9482 for root in &all_roots {
9483 let json_path = {
9484 let reg = state.registry.lock().await;
9485 reg.entries
9486 .iter()
9487 .find(|e| e.input_roots.iter().any(|r| r == root))
9488 .and_then(|e| e.json_path.clone())
9489 };
9490 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
9491 let json_str = tokio::fs::read_to_string(&p).await.ok();
9492 json_str
9493 .as_deref()
9494 .and_then(|s| serde_json::from_str(s).ok())
9495 } else {
9496 None
9497 };
9498 if let Some(ref run) = run_for_root {
9499 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
9500 }
9501 }
9502 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
9503}
9504
9505#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
9509 State(state): State<AppState>,
9510 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9511) -> Response {
9512 auto_scan_watched_dirs(&state).await;
9513 let watched_dirs_list: Vec<String> = {
9514 let wd = state.watched_dirs.lock().await;
9515 wd.dirs.iter().map(|p| p.display().to_string()).collect()
9516 };
9517 let latest_run: Option<AnalysisRun> = {
9518 let json_path = {
9519 let reg = state.registry.lock().await;
9520 reg.entries.first().and_then(|e| e.json_path.clone())
9521 };
9522 if let Some(p) = json_path {
9523 let json_str = tokio::fs::read_to_string(&p).await.ok();
9524 json_str
9525 .as_deref()
9526 .and_then(|s| serde_json::from_str(s).ok())
9527 } else {
9528 None
9529 }
9530 };
9531
9532 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
9534
9535 let cov_json: String = latest_run
9537 .as_ref()
9538 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9539 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
9540
9541 let _cov_tier_json: String = latest_run
9543 .as_ref()
9544 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9545 .map_or_else(
9546 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
9547 compute_cov_tier_json_str,
9548 );
9549
9550 let total_tests: u64 = latest_run
9551 .as_ref()
9552 .map_or(0, |r| r.summary_totals.test_count);
9553 let total_assertions: u64 = latest_run
9554 .as_ref()
9555 .map_or(0, |r| r.summary_totals.test_assertion_count);
9556 let total_suites: u64 = latest_run
9557 .as_ref()
9558 .map_or(0, |r| r.summary_totals.test_suite_count);
9559 let total_code: u64 = latest_run
9560 .as_ref()
9561 .map_or(0, |r| r.summary_totals.code_lines);
9562 let workspace_density: f64 = if total_code > 0 {
9563 total_tests as f64 / total_code as f64 * 1000.0
9564 } else {
9565 0.0
9566 };
9567 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
9568 r.totals_by_language
9569 .iter()
9570 .filter(|l| l.test_count > 0)
9571 .count()
9572 });
9573 let most_tested: String = latest_run
9574 .as_ref()
9575 .and_then(|r| {
9576 r.totals_by_language
9577 .iter()
9578 .filter(|l| l.test_count > 0)
9579 .max_by_key(|l| l.test_count)
9580 })
9581 .map_or_else(
9582 || "\u{2014}".to_string(),
9583 |l| l.language.display_name().to_string(),
9584 );
9585 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
9586 r.per_file_records
9587 .iter()
9588 .filter(|f| f.raw_line_categories.test_count > 0)
9589 .count() as u64
9590 });
9591 let total_files_analyzed: u64 = latest_run
9592 .as_ref()
9593 .map_or(0, |r| r.summary_totals.files_analyzed);
9594 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
9595
9596 let cov_line_pct_str: String = latest_run
9598 .as_ref()
9599 .filter(|r| r.summary_totals.coverage_lines_found > 0)
9600 .map_or_else(
9601 || "0".to_string(),
9602 |r| {
9603 format!(
9604 "{:.1}",
9605 r.summary_totals.coverage_lines_hit as f64
9606 / r.summary_totals.coverage_lines_found as f64
9607 * 100.0
9608 )
9609 },
9610 );
9611 let cov_fn_pct_str: String = latest_run
9612 .as_ref()
9613 .filter(|r| r.summary_totals.coverage_functions_found > 0)
9614 .map_or_else(
9615 || "0".to_string(),
9616 |r| {
9617 format!(
9618 "{:.1}",
9619 r.summary_totals.coverage_functions_hit as f64
9620 / r.summary_totals.coverage_functions_found as f64
9621 * 100.0
9622 )
9623 },
9624 );
9625 let cov_branch_pct_str: String = latest_run
9626 .as_ref()
9627 .filter(|r| r.summary_totals.coverage_branches_found > 0)
9628 .map_or_else(
9629 || "0".to_string(),
9630 |r| {
9631 format!(
9632 "{:.1}",
9633 r.summary_totals.coverage_branches_hit as f64
9634 / r.summary_totals.coverage_branches_found as f64
9635 * 100.0
9636 )
9637 },
9638 );
9639
9640 let cov_no_data_notice = if has_coverage {
9641 String::new()
9642 } else {
9643 String::from(
9644 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
9645<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>
9646<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
9647 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
9648 <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>
9649 <span style="color:var(--muted);font-size:12px;">·</span>
9650 <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>
9651 <span style="color:var(--muted);font-size:12px;">·</span>
9652 <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>
9653</div>
9654<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
9655</div>"#,
9656 )
9657 };
9658
9659 let workspace_density_str = format!("{workspace_density:.1}");
9660 let nonce = &csp_nonce;
9661 let version = env!("CARGO_PKG_VERSION");
9662
9663 let watched_dirs_html: String = if state.server_mode {
9666 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()
9667 } else {
9668 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
9669 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
9670 .to_string()
9671 } else {
9672 watched_dirs_list
9673 .iter()
9674 .fold(String::new(), |mut s, d| {
9675 use std::fmt::Write as _;
9676 let escaped =
9677 d.replace('&', "&").replace('"', """).replace('<', "<");
9678 write!(
9679 s,
9680 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>"#
9681 ).expect("write to String is infallible");
9682 s
9683 })
9684 };
9685 format!(
9686 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>"#
9687 )
9688 };
9689
9690 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
9692
9693 let html = format!(
9694 r#"<!doctype html>
9695<html lang="en">
9696<head>
9697 <meta charset="utf-8" />
9698 <meta name="viewport" content="width=device-width, initial-scale=1" />
9699 <title>OxideSLOC | Test Metrics</title>
9700 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9701 <style nonce="{nonce}">
9702 :root {{
9703 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
9704 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9705 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
9706 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9707 --info-bg:#eef3ff; --info-text:#4467d8;
9708 }}
9709 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
9710 *{{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;}}
9711 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9712 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
9713 .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;}}
9714 @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));}}}}
9715 .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);}}
9716 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
9717 .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));}}
9718 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9719 .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;}}
9720 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
9721 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
9722 @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; }} }}
9723 .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;}}
9724 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9725 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9726 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9727 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
9728 .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;}}
9729 .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;}}
9730 .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;}}
9731 .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;}}
9732 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9733 .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);}}
9734 .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;}}
9735 .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;}}
9736 .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;}}
9737 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9738 .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;}}
9739 .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);}}
9740 .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;}}
9741 .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;}}
9742 .tz-select:focus{{border-color:var(--oxide);}}
9743 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9744 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
9745 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
9746 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
9747 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
9748 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
9749 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
9750 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
9751 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
9752 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
9753 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
9754 .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;}}
9755 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;}}
9756 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
9757 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
9758 .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);}}
9759 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
9760 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
9761 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
9762 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9763 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
9764 .chart-canvas-wrap{{position:relative;height:280px;}}
9765 .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;}}
9766 .chart-no-data svg{{opacity:0.35;}}
9767 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
9768 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
9769 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9770 .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;}}
9771 .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;}}
9772 .data-table tr:last-child td{{border-bottom:none;}}
9773 .data-table tbody tr:hover td{{background:var(--surface-2);}}
9774 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
9775 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
9776 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
9777 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
9778 .cov-gauge-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .2s ease,box-shadow .2s ease;min-width:0;}}
9779 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
9780 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
9781 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
9782 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
9783 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
9784 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
9785 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
9786 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
9787 .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;}}
9788 .chart-select:focus{{border-color:var(--accent);}}
9789 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
9790 .trend-canvas-wrap{{position:relative;height:260px;}}
9791 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9792 .site-footer a{{color:var(--muted);}}
9793 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
9794 .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;}}
9795 .btn:hover{{background:var(--surface-2);}}
9796 .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;}}
9797 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9798 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
9799 .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;}}
9800 .scope-sel:focus{{border-color:var(--accent);}}
9801 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
9802 .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;}}
9803 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
9804 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9805 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
9806 .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;}}
9807 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
9808 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
9809 .watched-chip-rm:hover{{color:var(--oxide);}}
9810 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
9811 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
9812 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
9813 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
9814 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
9815 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
9816 .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;}}
9817 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
9818 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
9819 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
9820 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
9821 .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;}}
9822 .cov-file-search:focus{{border-color:var(--accent);}}
9823 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
9824 .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;}}
9825 body.dark-theme .cov-file-search{{background:var(--surface);}}
9826 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9827 .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;}}
9828 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9829 .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;}}
9830 .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);}}
9831 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
9832 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
9833 .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;}}
9834 .chart-modal-close:hover{{opacity:.7;}}
9835 body.dark-theme .chart-modal{{background:var(--surface);}}
9836 </style>
9837</head>
9838<body>
9839 <div class="background-watermarks" aria-hidden="true">
9840 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9841 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9842 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9843 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9844 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9845 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9846 </div>
9847 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9848 <div class="top-nav">
9849 <div class="top-nav-inner">
9850 <a class="brand" href="/">
9851 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9852 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
9853 </a>
9854 <div class="nav-right">
9855 <a class="nav-pill" href="/">Home</a>
9856 <div class="nav-dropdown">
9857 <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>
9858 <div class="nav-dropdown-menu">
9859 <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>
9860 </div>
9861 </div>
9862 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9863 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
9864 <div class="nav-dropdown">
9865 <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>
9866 <div class="nav-dropdown-menu">
9867 <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>
9868 </div>
9869 </div>
9870 <div class="server-status-wrap" id="server-status-wrap">
9871 <div class="nav-pill server-online-pill" id="server-status-pill">
9872 <span class="status-dot" id="status-dot"></span>
9873 <span id="server-status-label">Server</span>
9874 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9875 </div>
9876 <div class="server-status-tip">
9877 OxideSLOC is running — accessible on your network.
9878 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9879 </div>
9880 </div>
9881 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9882 <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>
9883 </button>
9884 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9885 <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>
9886 <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>
9887 </button>
9888 </div>
9889 </div>
9890 </div>
9891
9892 <div class="page">
9893 {watched_dirs_html}
9894 <div class="scope-bar">
9895 <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>
9896 <span class="scope-label">Scope</span>
9897 <div class="scope-sel-wrap">
9898 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
9899 <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);">
9900 <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>
9901 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
9902 </div>
9903 </div>
9904 </div>
9905 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9906 <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>
9907 <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>
9908 <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>
9909 <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>
9910 </div>
9911 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9912 <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>
9913 <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>
9914 <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>
9915 <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>
9916 </div>
9917
9918 <div class="panel" id="viz-panel">
9919 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
9920
9921 <div class="chart-box" style="margin-bottom:18px;">
9922 <div class="chart-box-header">
9923 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
9924 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9925 </div>
9926 <p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope.</p>
9927 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
9928 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
9929 </div>
9930
9931 <div class="chart-row">
9932 <div class="chart-box">
9933 <div class="chart-box-header">
9934 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
9935 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9936 </div>
9937 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
9938 <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>
9939 </div>
9940 <div class="chart-box">
9941 <div class="chart-box-header">
9942 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
9943 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9944 </div>
9945 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
9946 <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>
9947 </div>
9948 </div>
9949
9950 <div class="chart-row">
9951 <div class="chart-box">
9952 <div class="chart-box-header">
9953 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
9954 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9955 </div>
9956 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
9957 <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>
9958 </div>
9959 <div class="chart-box" id="suites-chart-box">
9960 <div class="chart-box-header">
9961 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
9962 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9963 </div>
9964 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
9965 <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>
9966 </div>
9967 </div>
9968
9969 <div class="chart-row">
9970 <div class="chart-box">
9971 <div class="chart-box-title">Test Files Breakdown</div>
9972 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
9973 <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>
9974 </div>
9975 <div class="chart-box">
9976 <div class="chart-box-title">Test Composition</div>
9977 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
9978 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
9979 <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>
9980 </div>
9981 </div>
9982 </div>
9983
9984 <div class="panel">
9985 <h1>Test Metrics</h1>
9986 <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>
9987
9988 <div class="section-header">Language Breakdown</div>
9989 {cov_no_data_notice}
9990 <div style="overflow-x:auto;">
9991 <table class="data-table" id="lang-table">
9992 <thead><tr>
9993 <th>Language</th>
9994 <th class="num">Test Fns</th>
9995 <th class="num">Assertions</th>
9996 <th class="num">Suites</th>
9997 <th class="num">Code Lines</th>
9998 <th class="num">Files</th>
9999 <th class="num">Density / 1K</th>
10000 <th>Relative Density</th>
10001 </tr></thead>
10002 <tbody id="lang-tbody"></tbody>
10003 </table>
10004 </div>
10005 </div>
10006
10007 <div class="panel" id="cov-panel" style="display:none;">
10008 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
10009 <div class="cov-gauge-row" id="cov-gauges">
10010 <div class="cov-gauge-card">
10011 <div class="cov-gauge-label">Line Coverage</div>
10012 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
10013 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
10014 <div class="cov-gauge-sub">Lines hit / instrumented</div>
10015 </div>
10016 <div class="cov-gauge-card">
10017 <div class="cov-gauge-label">Function Coverage</div>
10018 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
10019 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
10020 <div class="cov-gauge-sub">Functions hit / found</div>
10021 </div>
10022 <div class="cov-gauge-card">
10023 <div class="cov-gauge-label">Branch Coverage</div>
10024 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
10025 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
10026 <div class="cov-gauge-sub">Branches hit / found</div>
10027 </div>
10028 </div>
10029 <div class="chart-row">
10030 <div class="chart-box">
10031 <div class="chart-box-title">Line Coverage % by Language</div>
10032 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
10033 </div>
10034 <div class="chart-box">
10035 <div class="chart-box-title">Coverage Tier Distribution</div>
10036 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
10037 </div>
10038 </div>
10039
10040 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
10041 <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>
10042 <div class="cov-file-toolbar">
10043 <div class="cov-filter-tabs" id="cov-filter-tabs">
10044 <button class="cov-tab active" data-tier="all">All</button>
10045 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
10046 <button class="cov-tab" data-tier="low">Low (<50%)</button>
10047 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
10048 <button class="cov-tab" data-tier="high">High (≥80%)</button>
10049 </div>
10050 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
10051 </div>
10052 <div style="overflow-x:auto;">
10053 <table class="data-table" id="cov-file-table">
10054 <thead><tr>
10055 <th>File</th>
10056 <th>Lang</th>
10057 <th class="num">Line %</th>
10058 <th class="num">Lines Hit / Found</th>
10059 <th class="num">Fn %</th>
10060 <th class="num">Fns Hit / Found</th>
10061 </tr></thead>
10062 <tbody id="cov-file-tbody"></tbody>
10063 </table>
10064 </div>
10065 <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>
10066 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
10067 </div>
10068
10069 </div>
10070
10071 <footer class="site-footer">
10072 local code analysis - metrics, history and reports
10073 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
10074 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10075 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10076 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10077 · <a href="/api-docs" rel="noopener">REST API</a>
10078 </footer>
10079
10080 <script nonce="{nonce}">
10081 (function() {{
10082 // Theme
10083 var b = document.body;
10084 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
10085 var tgl = document.getElementById('theme-toggle');
10086 if (tgl) tgl.addEventListener('click', function() {{
10087 var d = b.classList.toggle('dark-theme');
10088 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
10089 }});
10090
10091 // Watermarks
10092 (function() {{
10093 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10094 if (!wms.length) return;
10095 var placed = [];
10096 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;}}
10097 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];}}
10098 var half=Math.floor(wms.length/2);
10099 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;}});
10100 }})();
10101
10102 // Code particles
10103 (function() {{
10104 var container = document.getElementById('code-particles');
10105 if (!container) return;
10106 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
10107 for (var i = 0; i < 36; i++) {{
10108 (function(idx) {{
10109 var el = document.createElement('span');
10110 el.className = 'code-particle';
10111 el.textContent = snippets[idx % snippets.length];
10112 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
10113 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
10114 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
10115 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';
10116 container.appendChild(el);
10117 }})(i);
10118 }}
10119 }})();
10120
10121 // Settings modal
10122 (function() {{
10123 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'}}];
10124 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);}});}}
10125 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10126 var btn=document.getElementById('settings-btn');if(!btn)return;
10127 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10128 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>';
10129 document.body.appendChild(m);
10130 var g=document.getElementById('scheme-grid');
10131 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);}});
10132 var cl=document.getElementById('settings-close');
10133 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');}});
10134 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10135 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10136 }})();
10137
10138 // Watched folder picker
10139 (function() {{
10140 var btn = document.getElementById('add-watched-btn');
10141 if (!btn) return;
10142 btn.addEventListener('click', function() {{
10143 fetch('/pick-directory?kind=reports')
10144 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10145 .then(function(data) {{
10146 if (!data.cancelled && data.selected_path) {{
10147 var form = document.createElement('form');
10148 form.method = 'POST';
10149 form.action = '/watched-dirs/add';
10150 var ri = document.createElement('input');
10151 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10152 var fi = document.createElement('input');
10153 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10154 form.appendChild(ri); form.appendChild(fi);
10155 document.body.appendChild(form);
10156 form.submit();
10157 }}
10158 }})
10159 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10160 }});
10161 }})();
10162 }})();
10163 </script>
10164
10165 <script src="/static/chart.js" nonce="{nonce}"></script>
10166 <script nonce="{nonce}">
10167 (function() {{
10168 var SCOPE_DATA = {scope_data_json};
10169 var currentRoot = '__all__';
10170 var currentSub = '';
10171 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
10172 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
10173 var ALL_CHARTS = [];
10174 var currentLangTests = [];
10175 var currentTrendPts = [];
10176
10177 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();}}
10178 function fmtFull(n){{return Number(n).toLocaleString();}}
10179 function isDark(){{return document.body.classList.contains('dark-theme');}}
10180 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
10181 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
10182 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
10183
10184 function makeDlPlugin(fmtFn, anchor) {{
10185 return {{
10186 afterDatasetsDraw: function(chart) {{
10187 var ctx = chart.ctx;
10188 var tc = txtClr();
10189 chart.data.datasets.forEach(function(ds, di) {{
10190 var meta = chart.getDatasetMeta(di);
10191 meta.data.forEach(function(el, idx) {{
10192 var label = fmtFn(ds.data[idx], di, idx);
10193 if (label == null || label === '') return;
10194 ctx.save();
10195 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
10196 ctx.fillStyle = tc;
10197 if (anchor === 'top') {{
10198 ctx.textAlign = 'center';
10199 ctx.textBaseline = 'bottom';
10200 ctx.fillText(String(label), el.x, el.y - 5);
10201 }} else {{
10202 ctx.textAlign = 'left';
10203 ctx.textBaseline = 'middle';
10204 ctx.fillText(String(label), el.x + 5, el.y);
10205 }}
10206 ctx.restore();
10207 }});
10208 }});
10209 }}
10210 }};
10211 }}
10212
10213 function makeTmOverlay(title, subtitle, h) {{
10214 var overlay = document.createElement('div');
10215 overlay.className = 'chart-modal-overlay';
10216 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
10217 var ch = Math.min(h || 560, maxH);
10218 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
10219 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>';
10220 document.body.appendChild(overlay);
10221 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
10222 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
10223 return document.getElementById('tm-modal-canvas');
10224 }}
10225
10226 function getDataset() {{
10227 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10228 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
10229 return r;
10230 }}
10231 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
10232
10233 function showNoData(id, show) {{
10234 var el = document.getElementById(id);
10235 if (!el) return;
10236 var wrap = el.previousElementSibling;
10237 el.style.display = show ? '' : 'none';
10238 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
10239 }}
10240
10241 function renderTestCharts(D) {{
10242 currentLangTests = D || [];
10243 testsChart = destroyChart(testsChart);
10244 densityChart = destroyChart(densityChart);
10245 if (!D || !D.length) {{
10246 showNoData('no-data-tests', true);
10247 showNoData('no-data-density', true);
10248 return;
10249 }}
10250 showNoData('no-data-tests', false);
10251 showNoData('no-data-density', false);
10252 var top15 = D.slice(0, 15);
10253 var canvas1 = document.getElementById('canvas-tests');
10254 if (canvas1) {{
10255 testsChart = new Chart(canvas1, {{
10256 type: 'bar',
10257 data: {{
10258 labels: top15.map(function(d){{ return d.lang; }}),
10259 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10260 }},
10261 options: {{
10262 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10263 layout: {{ padding: {{ right: 64 }} }},
10264 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10265 scales: {{
10266 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10267 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10268 }}
10269 }},
10270 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10271 }});
10272 ALL_CHARTS.push(testsChart);
10273 }}
10274 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
10275 var canvas2 = document.getElementById('canvas-density');
10276 if (canvas2) {{
10277 densityChart = new Chart(canvas2, {{
10278 type: 'bar',
10279 data: {{
10280 labels: topD.map(function(d){{ return d.lang; }}),
10281 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 }}]
10282 }},
10283 options: {{
10284 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10285 layout: {{ padding: {{ right: 64 }} }},
10286 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10287 scales: {{
10288 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10289 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10290 }}
10291 }},
10292 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10293 }});
10294 ALL_CHARTS.push(densityChart);
10295 }}
10296 }}
10297
10298 function renderAssertionsChart(D) {{
10299 assertionsChart = destroyChart(assertionsChart);
10300 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
10301 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10302 var canvas = document.getElementById('canvas-assertions');
10303 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
10304 showNoData('no-data-assertions', false);
10305 assertionsChart = new Chart(canvas, {{
10306 type: 'bar',
10307 data: {{
10308 labels: top15.map(function(d){{ return d.lang; }}),
10309 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10310 }},
10311 options: {{
10312 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10313 layout: {{ padding: {{ right: 64 }} }},
10314 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10315 scales: {{
10316 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10317 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10318 }}
10319 }},
10320 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10321 }});
10322 ALL_CHARTS.push(assertionsChart);
10323 }}
10324
10325 function renderSuitesChart(D) {{
10326 suitesChart = destroyChart(suitesChart);
10327 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
10328 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10329 var canvas = document.getElementById('canvas-suites');
10330 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
10331 showNoData('no-data-suites', false);
10332 suitesChart = new Chart(canvas, {{
10333 type: 'bar',
10334 data: {{
10335 labels: top15.map(function(d){{ return d.lang; }}),
10336 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 }}]
10337 }},
10338 options: {{
10339 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10340 layout: {{ padding: {{ right: 64 }} }},
10341 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10342 scales: {{
10343 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10344 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10345 }}
10346 }},
10347 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10348 }});
10349 ALL_CHARTS.push(suitesChart);
10350 }}
10351
10352 function renderFilesChart(totals) {{
10353 filesChart = destroyChart(filesChart);
10354 var canvas = document.getElementById('canvas-files');
10355 if (!canvas) return;
10356 var testF = totals.test_files || 0;
10357 var totalF = totals.total_files || 0;
10358 var nonTest = Math.max(0, totalF - testF);
10359 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
10360 showNoData('no-data-files', false);
10361 var dark = isDark();
10362 filesChart = new Chart(canvas, {{
10363 type: 'doughnut',
10364 data: {{
10365 labels: ['Test Files', 'Non-Test Files'],
10366 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
10367 }},
10368 options: {{
10369 responsive: true, maintainAspectRatio: false, cutout: '62%',
10370 plugins: {{
10371 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10372 tooltip: {{ callbacks: {{ label: function(ctx) {{
10373 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
10374 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
10375 }} }} }}
10376 }}
10377 }}
10378 }});
10379 ALL_CHARTS.push(filesChart);
10380 }}
10381
10382 function renderCompositionChart(totals) {{
10383 compositionChart = destroyChart(compositionChart);
10384 var canvas = document.getElementById('canvas-composition');
10385 if (!canvas) return;
10386 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
10387 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
10388 showNoData('no-data-composition', false);
10389 compositionChart = new Chart(canvas, {{
10390 type: 'bar',
10391 data: {{
10392 labels: ['Test Functions', 'Assertions', 'Test Suites'],
10393 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
10394 }},
10395 options: {{
10396 responsive: true, maintainAspectRatio: false,
10397 layout: {{ padding: {{ top: 22 }} }},
10398 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
10399 scales: {{
10400 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
10401 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10402 }}
10403 }},
10404 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10405 }});
10406 ALL_CHARTS.push(compositionChart);
10407 }}
10408
10409 function renderCovCharts(covD, tiers) {{
10410 covChart = destroyChart(covChart);
10411 tierChart = destroyChart(tierChart);
10412 var covCanvas = document.getElementById('canvas-cov');
10413 if (covCanvas && covD && covD.length) {{
10414 covChart = new Chart(covCanvas, {{
10415 type: 'bar',
10416 data: {{
10417 labels: covD.map(function(d){{ return d.lang; }}),
10418 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 }}]
10419 }},
10420 options: {{
10421 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10422 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
10423 scales: {{
10424 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
10425 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10426 }}
10427 }}
10428 }});
10429 ALL_CHARTS.push(covChart);
10430 }}
10431 var tierCanvas = document.getElementById('canvas-cov-tiers');
10432 if (tierCanvas && tiers) {{
10433 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
10434 tierChart = new Chart(tierCanvas, {{
10435 type: 'doughnut',
10436 data: {{
10437 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
10438 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
10439 }},
10440 options: {{
10441 responsive: true, maintainAspectRatio: false, cutout: '62%',
10442 plugins: {{
10443 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10444 tooltip: {{ callbacks: {{ label: function(ctx) {{
10445 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
10446 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
10447 }} }} }}
10448 }}
10449 }}
10450 }});
10451 ALL_CHARTS.push(tierChart);
10452 }}
10453 }}
10454
10455 function buildLangTable(D) {{
10456 var tbody = document.getElementById('lang-tbody');
10457 if (!tbody) return;
10458 if (!D || !D.length) {{
10459 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>';
10460 return;
10461 }}
10462 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
10463 tbody.innerHTML = D.map(function(d) {{
10464 var barW = Math.round(d.density / maxDensity * 120);
10465 return '<tr>' +
10466 '<td><strong>' + d.lang + '</strong></td>' +
10467 '<td class="num">' + fmt(d.tests) + '</td>' +
10468 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
10469 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
10470 '<td class="num">' + fmt(d.code) + '</td>' +
10471 '<td class="num">' + fmt(d.files) + '</td>' +
10472 '<td class="num">' + d.density.toFixed(2) + '</td>' +
10473 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
10474 '</tr>';
10475 }}).join('');
10476 }}
10477
10478 var covFileData = [];
10479 var covFileTier = 'all';
10480 var covFileSearch = '';
10481
10482 function pctBadge(pct) {{
10483 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
10484 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
10485 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
10486 }}
10487
10488 function buildCovFileTable() {{
10489 var tbody = document.getElementById('cov-file-tbody');
10490 var empty = document.getElementById('cov-file-empty');
10491 var count = document.getElementById('cov-file-count');
10492 if (!tbody) return;
10493 var srch = covFileSearch.toLowerCase();
10494 var filtered = covFileData.filter(function(f) {{
10495 if (covFileTier === 'zero' && f.line_pct > 0) return false;
10496 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
10497 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
10498 if (covFileTier === 'high' && f.line_pct < 80) return false;
10499 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
10500 return true;
10501 }});
10502 if (!filtered.length) {{
10503 tbody.innerHTML = '';
10504 if (empty) empty.style.display = '';
10505 if (count) count.textContent = '';
10506 return;
10507 }}
10508 if (empty) empty.style.display = 'none';
10509 var shown = Math.min(filtered.length, 500);
10510 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
10511 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
10512 var fnCol = f.fn_pct < 0
10513 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
10514 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
10515 return '<tr>' +
10516 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
10517 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
10518 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
10519 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
10520 fnCol +
10521 '</tr>';
10522 }}).join('');
10523 }}
10524
10525 (function() {{
10526 var tabs = document.getElementById('cov-filter-tabs');
10527 if (tabs) {{
10528 tabs.addEventListener('click', function(e) {{
10529 var btn = e.target.closest('.cov-tab');
10530 if (!btn) return;
10531 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
10532 btn.classList.add('active');
10533 covFileTier = btn.getAttribute('data-tier');
10534 buildCovFileTable();
10535 }});
10536 }}
10537 var srch = document.getElementById('cov-file-search');
10538 if (srch) {{
10539 srch.addEventListener('input', function() {{
10540 covFileSearch = this.value;
10541 buildCovFileTable();
10542 }});
10543 }}
10544 }})();
10545
10546 function updateCovGauges(t) {{
10547 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
10548 var el;
10549 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
10550 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
10551 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
10552 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
10553 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
10554 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
10555 }}
10556
10557 function applyScope() {{
10558 var d = getDataset();
10559 var t = d.totals;
10560 var el;
10561 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
10562 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
10563 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
10564 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
10565 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
10566 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
10567 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
10568 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
10569 renderTestCharts(d.lang_tests);
10570 renderAssertionsChart(d.lang_tests);
10571 renderSuitesChart(d.lang_tests);
10572 renderFilesChart(t);
10573 renderCompositionChart(t);
10574 buildLangTable(d.lang_tests);
10575 var covPanel = document.getElementById('cov-panel');
10576 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
10577 if (d.has_coverage) {{
10578 renderCovCharts(d.cov, d.cov_tiers);
10579 updateCovGauges(t);
10580 covFileData = d.file_cov || [];
10581 covFileTier = 'all';
10582 covFileSearch = '';
10583 var tabs = document.getElementById('cov-filter-tabs');
10584 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
10585 var srch = document.getElementById('cov-file-search');
10586 if (srch) srch.value = '';
10587 buildCovFileTable();
10588 }}
10589 loadTrend();
10590 }}
10591
10592 // Populate scope-root-sel from SCOPE_DATA keys
10593 (function() {{
10594 var sel = document.getElementById('scope-root-sel');
10595 if (!sel) return;
10596 Object.keys(SCOPE_DATA).forEach(function(k) {{
10597 if (k === '__all__') return;
10598 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
10599 }});
10600 }})();
10601
10602 document.getElementById('scope-root-sel').addEventListener('change', function() {{
10603 currentRoot = this.value;
10604 currentSub = '';
10605 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10606 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
10607 var subWrap = document.getElementById('scope-sub-wrap');
10608 var subSel = document.getElementById('scope-sub-sel');
10609 subSel.innerHTML = '<option value="">Entire project</option>';
10610 if (subNames.length) {{
10611 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
10612 subWrap.style.display = 'flex';
10613 }} else {{
10614 subWrap.style.display = 'none';
10615 }}
10616 applyScope();
10617 }});
10618
10619 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
10620 currentSub = this.value;
10621 applyScope();
10622 }});
10623
10624 function buildTrend(data) {{
10625 var trendCanvas = document.getElementById('canvas-trend');
10626 var trendEmpty = document.getElementById('trend-empty');
10627 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
10628 pts = pts.slice().reverse();
10629 currentTrendPts = pts;
10630 if (!pts.length) {{
10631 if (trendCanvas) trendCanvas.style.display = 'none';
10632 if (trendEmpty) trendEmpty.style.display = '';
10633 return;
10634 }}
10635 if (trendCanvas) trendCanvas.style.display = '';
10636 if (trendEmpty) trendEmpty.style.display = 'none';
10637 trendChart = destroyChart(trendChart);
10638 if (!trendCanvas) return;
10639 trendChart = new Chart(trendCanvas, {{
10640 type: 'line',
10641 data: {{
10642 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10643 datasets: [{{
10644 label: 'Test Definitions',
10645 data: pts.map(function(d){{ return d.test_count; }}),
10646 borderColor: '#C45C10',
10647 backgroundColor: 'rgba(196,92,16,0.10)',
10648 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10649 pointRadius: 5, fill: true, tension: 0.3
10650 }}]
10651 }},
10652 options: {{
10653 responsive: true, maintainAspectRatio: false,
10654 layout: {{ padding: {{ top: 22 }} }},
10655 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10656 scales: {{
10657 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
10658 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10659 }}
10660 }},
10661 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10662 }});
10663 ALL_CHARTS.push(trendChart);
10664 }}
10665
10666 // ── Full View expand buttons ──────────────────────────────────────────────
10667 (function() {{
10668 var btn = document.getElementById('tests-expand-btn');
10669 if (!btn) return;
10670 btn.addEventListener('click', function() {{
10671 var D = currentLangTests;
10672 if (!D || !D.length) return;
10673 var top15 = D.slice(0, 15);
10674 var h = Math.max(320, top15.length * 36 + 80);
10675 var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
10676 if (!canvas) return;
10677 new Chart(canvas, {{
10678 type: 'bar',
10679 data: {{
10680 labels: top15.map(function(d){{ return d.lang; }}),
10681 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10682 }},
10683 options: {{
10684 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10685 layout: {{ padding: {{ right: 72 }} }},
10686 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10687 scales: {{
10688 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10689 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10690 }}
10691 }},
10692 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10693 }});
10694 }});
10695 }})();
10696
10697 (function() {{
10698 var btn = document.getElementById('density-expand-btn');
10699 if (!btn) return;
10700 btn.addEventListener('click', function() {{
10701 var D = currentLangTests;
10702 if (!D || !D.length) return;
10703 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
10704 var h = Math.max(320, topD.length * 36 + 80);
10705 var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
10706 if (!canvas) return;
10707 new Chart(canvas, {{
10708 type: 'bar',
10709 data: {{
10710 labels: topD.map(function(d){{ return d.lang; }}),
10711 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 }}]
10712 }},
10713 options: {{
10714 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10715 layout: {{ padding: {{ right: 72 }} }},
10716 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10717 scales: {{
10718 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10719 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10720 }}
10721 }},
10722 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10723 }});
10724 }});
10725 }})();
10726
10727 (function() {{
10728 var btn = document.getElementById('trend-expand-btn');
10729 if (!btn) return;
10730 btn.addEventListener('click', function() {{
10731 var pts = currentTrendPts;
10732 if (!pts || !pts.length) return;
10733 var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
10734 if (!canvas) return;
10735 new Chart(canvas, {{
10736 type: 'line',
10737 data: {{
10738 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10739 datasets: [{{
10740 label: 'Test Definitions',
10741 data: pts.map(function(d){{ return d.test_count; }}),
10742 borderColor: '#C45C10',
10743 backgroundColor: 'rgba(196,92,16,0.10)',
10744 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10745 pointRadius: 5, fill: true, tension: 0.3
10746 }}]
10747 }},
10748 options: {{
10749 responsive: true, maintainAspectRatio: false,
10750 layout: {{ padding: {{ top: 22 }} }},
10751 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10752 scales: {{
10753 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
10754 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10755 }}
10756 }},
10757 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10758 }});
10759 }});
10760 }})();
10761
10762 (function() {{
10763 var btn = document.getElementById('assertions-expand-btn');
10764 if (!btn) return;
10765 btn.addEventListener('click', function() {{
10766 var D = currentLangTests;
10767 if (!D || !D.length) return;
10768 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10769 if (!top15.length) return;
10770 var h = Math.max(320, top15.length * 36 + 80);
10771 var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
10772 if (!canvas) return;
10773 new Chart(canvas, {{
10774 type: 'bar',
10775 data: {{
10776 labels: top15.map(function(d){{ return d.lang; }}),
10777 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10778 }},
10779 options: {{
10780 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10781 layout: {{ padding: {{ right: 72 }} }},
10782 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10783 scales: {{
10784 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10785 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10786 }}
10787 }},
10788 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10789 }});
10790 }});
10791 }})();
10792
10793 (function() {{
10794 var btn = document.getElementById('suites-expand-btn');
10795 if (!btn) return;
10796 btn.addEventListener('click', function() {{
10797 var D = currentLangTests;
10798 if (!D || !D.length) return;
10799 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10800 if (!top15.length) return;
10801 var h = Math.max(320, top15.length * 36 + 80);
10802 var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
10803 if (!canvas) return;
10804 new Chart(canvas, {{
10805 type: 'bar',
10806 data: {{
10807 labels: top15.map(function(d){{ return d.lang; }}),
10808 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 }}]
10809 }},
10810 options: {{
10811 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10812 layout: {{ padding: {{ right: 72 }} }},
10813 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10814 scales: {{
10815 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10816 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10817 }}
10818 }},
10819 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10820 }});
10821 }});
10822 }})();
10823
10824 function loadTrend() {{
10825 var url = '/api/metrics/history?limit=100';
10826 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
10827 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
10828 buildTrend(data);
10829 }}).catch(function(){{
10830 var trendEmpty = document.getElementById('trend-empty');
10831 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
10832 }});
10833 }}
10834
10835 // Re-render charts on theme toggle
10836 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
10837 setTimeout(function() {{
10838 ALL_CHARTS.forEach(function(c) {{
10839 if (c && c.options && c.options.scales) {{
10840 Object.values(c.options.scales).forEach(function(ax) {{
10841 if (ax.grid) ax.grid.color = clr();
10842 if (ax.ticks) ax.ticks.color = txtClr();
10843 }});
10844 c.update();
10845 }}
10846 }});
10847 }}, 80);
10848 }});
10849
10850 applyScope();
10851 }})();
10852 </script>
10853 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
10854</body>
10855</html>"#,
10856 );
10857 Html(html).into_response()
10858}
10859
10860#[derive(Deserialize)]
10867struct EmbedQuery {
10868 run_id: Option<String>,
10869 theme: Option<String>,
10870}
10871
10872async fn embed_handler(
10873 State(state): State<AppState>,
10874 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10875 Query(query): Query<EmbedQuery>,
10876) -> Response {
10877 let entry = {
10878 let reg = state.registry.lock().await;
10879 query.run_id.as_ref().map_or_else(
10880 || reg.entries.first().cloned(),
10881 |id| reg.find_by_run_id(id).cloned(),
10882 )
10883 };
10884
10885 let Some(entry) = entry else {
10886 return Html(
10887 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
10888 .to_string(),
10889 )
10890 .into_response();
10891 };
10892
10893 let dark = query.theme.as_deref() == Some("dark");
10894 let languages: Vec<(String, u64, u64)> = entry
10895 .json_path
10896 .as_ref()
10897 .and_then(|p| read_json(p).ok())
10898 .map(|run| {
10899 run.totals_by_language
10900 .iter()
10901 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
10902 .collect()
10903 })
10904 .unwrap_or_default();
10905
10906 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
10907}
10908
10909fn render_embed_widget(
10910 entry: &RegistryEntry,
10911 languages: &[(String, u64, u64)],
10912 dark: bool,
10913 csp_nonce: &str,
10914) -> String {
10915 let s = &entry.summary;
10916 let total = s.code_lines + s.comment_lines + s.blank_lines;
10917 let code_pct = s
10918 .code_lines
10919 .checked_mul(100)
10920 .and_then(|n| n.checked_div(total))
10921 .unwrap_or(0);
10922
10923 let (bg, fg, surface, muted, border) = if dark {
10924 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
10925 } else {
10926 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
10927 };
10928
10929 let mut lang_rows = String::new();
10930 for (name, files, code) in languages {
10931 write!(
10932 lang_rows,
10933 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
10934 escape_html(name),
10935 format_number(*files),
10936 format_number(*code),
10937 )
10938 .ok();
10939 }
10940
10941 let lang_table = if lang_rows.is_empty() {
10942 String::new()
10943 } else {
10944 format!(
10945 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
10946 )
10947 };
10948
10949 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
10950 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
10951 let project_esc = escape_html(&entry.project_label);
10952 let code_lines = format_number(s.code_lines);
10953 let comment_lines = format_number(s.comment_lines);
10954 let files = format_number(s.files_analyzed);
10955 let code_raw = s.code_lines;
10956 let comment_raw = s.comment_lines;
10957 let blank_raw = s.blank_lines;
10958
10959 format!(
10960 r#"<!doctype html>
10961<html lang="en">
10962<head>
10963 <meta charset="utf-8">
10964 <meta name="viewport" content="width=device-width,initial-scale=1">
10965 <title>OxideSLOC — {project_esc}</title>
10966 <script src="/static/chart.js"></script>
10967 <style nonce="{csp_nonce}">
10968 *{{box-sizing:border-box;margin:0;padding:0}}
10969 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
10970 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
10971 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
10972 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
10973 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
10974 .card .v{{font-size:18px;font-weight:700}}
10975 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
10976 .row{{display:flex;gap:12px;align-items:flex-start}}
10977 .pie{{width:120px;height:120px;flex-shrink:0}}
10978 .lt{{border-collapse:collapse;width:100%;flex:1}}
10979 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
10980 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
10981 .n{{text-align:right}}
10982 .footer{{margin-top:10px;color:{muted};font-size:10px}}
10983 </style>
10984</head>
10985<body>
10986 <h2>{project_esc}</h2>
10987 <div class="sub">{timestamp} · run {run_short}</div>
10988 <div class="cards">
10989 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
10990 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
10991 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
10992 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
10993 </div>
10994 <div class="row">
10995 <canvas class="pie" id="c"></canvas>
10996 {lang_table}
10997 </div>
10998 <div class="footer">oxide-sloc</div>
10999 <script nonce="{csp_nonce}">
11000 new Chart(document.getElementById('c'),{{
11001 type:'doughnut',
11002 data:{{
11003 labels:['Code','Comments','Blank'],
11004 datasets:[{{
11005 data:[{code_raw},{comment_raw},{blank_raw}],
11006 backgroundColor:['#4a78ee','#b35428','#aaa'],
11007 borderWidth:0
11008 }}]
11009 }},
11010 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
11011 }});
11012 </script>
11013</body>
11014</html>"#
11015 )
11016}
11017
11018#[allow(clippy::too_many_lines)]
11019fn persist_run_artifacts(
11020 run: &sloc_core::AnalysisRun,
11021 report_html: &str,
11022 run_dir: &Path,
11023 report_title: &str,
11024 file_stem: &str,
11025 result_context: RunResultContext,
11026) -> Result<(RunArtifacts, PendingPdf)> {
11027 let html_dir = run_dir.join("html");
11029 let pdf_dir = run_dir.join("pdf");
11030 let excel_dir = run_dir.join("excel");
11031 let json_dir = run_dir.join("json");
11032 let submodules_dir = run_dir.join("submodules");
11033 for dir in &[
11034 run_dir,
11035 &html_dir,
11036 &pdf_dir,
11037 &excel_dir,
11038 &json_dir,
11039 &submodules_dir,
11040 ] {
11041 fs::create_dir_all(dir)
11042 .with_context(|| format!("failed to create directory {}", dir.display()))?;
11043 }
11044
11045 let html_path = {
11047 let path = html_dir.join(format!("report_{file_stem}.html"));
11048 fs::write(&path, report_html)
11049 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
11050 Some(path)
11051 };
11052
11053 let json_path = {
11055 let path = json_dir.join(format!("result_{file_stem}.json"));
11056 let json = serde_json::to_string_pretty(run)
11057 .context("failed to serialize analysis run to JSON")?;
11058 fs::write(&path, json)
11059 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
11060 Some(path)
11061 };
11062
11063 let (pdf_path, pending_pdf) = {
11065 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
11066 match write_pdf_from_run(run, &pdf_dest) {
11067 Ok(()) => {
11068 eprintln!(
11069 "[oxide-sloc][pdf] native PDF written to {}",
11070 pdf_dest.display()
11071 );
11072 (Some(pdf_dest), None)
11073 }
11074 Err(native_err) => {
11075 eprintln!(
11076 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
11077 );
11078 let source_html_path = html_path
11079 .as_ref()
11080 .expect("html_path always Some here")
11081 .clone();
11082 let pending = Some((source_html_path, pdf_dest.clone(), false));
11083 (Some(pdf_dest), pending)
11084 }
11085 }
11086 };
11087
11088 let csv_path = {
11090 let path = excel_dir.join(format!("report_{file_stem}.csv"));
11091 if let Err(e) = sloc_report::write_csv(run, &path) {
11092 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
11093 None
11094 } else {
11095 Some(path)
11096 }
11097 };
11098
11099 let xlsx_path = {
11100 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
11101 if let Err(e) = sloc_report::write_xlsx(run, &path) {
11102 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
11103 None
11104 } else {
11105 Some(path)
11106 }
11107 };
11108
11109 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
11111
11112 if run.effective_configuration.discovery.submodule_breakdown {
11114 let run_id = &run.tool.run_id;
11115 for s in &run.submodule_summaries {
11116 build_submodule_row(s, run, run_id, run_dir);
11117 }
11118 }
11119
11120 generate_offline_index(
11122 run,
11123 run_dir,
11124 file_stem,
11125 html_path.as_deref(),
11126 pdf_path.as_deref(),
11127 json_path.as_deref(),
11128 scan_config_path.as_deref(),
11129 &result_context,
11130 );
11131
11132 Ok((
11133 RunArtifacts {
11134 output_dir: run_dir.to_path_buf(),
11135 html_path,
11136 pdf_path,
11137 json_path,
11138 csv_path,
11139 xlsx_path,
11140 scan_config_path,
11141 report_title: report_title.to_string(),
11142 result_context,
11143 },
11144 pending_pdf,
11145 ))
11146}
11147
11148#[allow(clippy::too_many_arguments)]
11151#[allow(clippy::too_many_lines)]
11152#[allow(clippy::similar_names)]
11153fn generate_offline_index(
11154 run: &sloc_core::AnalysisRun,
11155 run_dir: &Path,
11156 file_stem: &str,
11157 html_path: Option<&Path>,
11158 pdf_path: Option<&Path>,
11159 json_path: Option<&Path>,
11160 scan_config_path: Option<&Path>,
11161 result_context: &RunResultContext,
11162) {
11163 let prev_entry = &result_context.prev_entry;
11164 let prev_scan_count = result_context.prev_scan_count;
11165 let project_path = &result_context.project_path;
11166
11167 let scan_delta = prev_entry.as_ref().and_then(|prev| {
11168 prev.json_path
11169 .as_ref()
11170 .and_then(|p| read_json(p).ok())
11171 .map(|prev_run| compute_delta(&prev_run, run))
11172 });
11173
11174 let files_analyzed = run.per_file_records.len() as u64;
11175 let files_skipped = run.skipped_file_records.len() as u64;
11176 let physical_lines = run
11177 .totals_by_language
11178 .iter()
11179 .map(|r| r.total_physical_lines)
11180 .sum::<u64>();
11181 let code_lines = run
11182 .totals_by_language
11183 .iter()
11184 .map(|r| r.code_lines)
11185 .sum::<u64>();
11186 let comment_lines = run
11187 .totals_by_language
11188 .iter()
11189 .map(|r| r.comment_lines)
11190 .sum::<u64>();
11191 let blank_lines = run
11192 .totals_by_language
11193 .iter()
11194 .map(|r| r.blank_lines)
11195 .sum::<u64>();
11196 let mixed_lines = run
11197 .totals_by_language
11198 .iter()
11199 .map(|r| r.mixed_lines_separate)
11200 .sum::<u64>();
11201 let functions = run
11202 .totals_by_language
11203 .iter()
11204 .map(|r| r.functions)
11205 .sum::<u64>();
11206 let classes = run
11207 .totals_by_language
11208 .iter()
11209 .map(|r| r.classes)
11210 .sum::<u64>();
11211 let variables = run
11212 .totals_by_language
11213 .iter()
11214 .map(|r| r.variables)
11215 .sum::<u64>();
11216 let imports = run
11217 .totals_by_language
11218 .iter()
11219 .map(|r| r.imports)
11220 .sum::<u64>();
11221
11222 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
11223 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
11224 let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
11225 let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
11226 let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
11227 let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
11228 let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
11229 let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
11230
11231 let (delta_fa_str, delta_fa_class) =
11232 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
11233 let (delta_fs_str, delta_fs_class) =
11234 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
11235 let (delta_pl_str, delta_pl_class) =
11236 summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
11237 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
11238 let (delta_cml_str, delta_cml_class) =
11239 summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
11240 let (delta_bl_str, delta_bl_class) =
11241 summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
11242
11243 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
11244 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
11245 let (delta_lines_net_str, delta_lines_net_class) =
11246 match (delta_lines_added, delta_lines_removed) {
11247 (Some(a), Some(r)) => {
11248 let net = a - r;
11249 (fmt_delta(net), delta_class(net).to_string())
11250 }
11251 _ => ("\u{2014}".to_string(), "na".to_string()),
11252 };
11253
11254 let git_commit_url = run
11255 .git_remote_url
11256 .as_deref()
11257 .zip(run.git_commit_long.as_deref())
11258 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
11259 let git_branch_url = run
11260 .git_remote_url
11261 .as_deref()
11262 .zip(run.git_branch.as_deref())
11263 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
11264 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
11265 format!(
11266 "{} / {}",
11267 run.environment.initiator_username, run.environment.initiator_hostname
11268 )
11269 });
11270
11271 let make_rel = |p: Option<&Path>| -> Option<String> {
11273 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
11274 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
11275 };
11276
11277 let run_id = &run.tool.run_id;
11278
11279 let submodule_rows: Vec<SubmoduleRow> = run
11281 .submodule_summaries
11282 .iter()
11283 .map(|s| {
11284 let safe = sanitize_project_label(&s.name);
11285 let key = format!("sub_{safe}");
11286 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
11287 SubmoduleRow {
11288 name: s.name.clone(),
11289 relative_path: s.relative_path.clone(),
11290 files_analyzed: s.files_analyzed,
11291 code_lines: s.code_lines,
11292 comment_lines: s.comment_lines,
11293 blank_lines: s.blank_lines,
11294 total_physical_lines: s.total_physical_lines,
11295 html_url: if sub_path.exists() {
11296 Some(format!("submodules/{key}.html"))
11297 } else {
11298 None
11299 },
11300 }
11301 })
11302 .collect();
11303
11304 let lang_chart_json = {
11305 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
11306 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
11307 let entries: Vec<String> = langs
11308 .into_iter()
11309 .take(12)
11310 .map(|l| {
11311 let name = l.language.display_name()
11312 .replace('\\', "\\\\")
11313 .replace('"', "\\\"");
11314 format!(
11315 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
11316 name, l.code_lines, l.comment_lines, l.blank_lines,
11317 l.total_physical_lines, l.functions, l.classes,
11318 l.variables, l.imports, l.files
11319 )
11320 })
11321 .collect();
11322 format!("[{}]", entries.join(","))
11323 };
11324
11325 let scan_config_rel =
11326 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
11327
11328 let template = ResultTemplate {
11329 version: env!("CARGO_PKG_VERSION"),
11330 report_title: run.effective_configuration.reporting.report_title.clone(),
11331 project_path: project_path.clone(),
11332 output_dir: display_path(run_dir),
11333 run_id: run_id.clone(),
11334 run_id_short: run_id
11335 .split('-')
11336 .next_back()
11337 .unwrap_or(run_id)
11338 .chars()
11339 .take(7)
11340 .collect(),
11341 files_analyzed,
11342 files_skipped,
11343 physical_lines,
11344 code_lines,
11345 comment_lines,
11346 blank_lines,
11347 mixed_lines,
11348 functions,
11349 classes,
11350 variables,
11351 imports,
11352 html_url: make_rel(html_path),
11353 pdf_url: make_rel(pdf_path),
11354 json_url: make_rel(json_path),
11355 html_download_url: make_rel(html_path),
11356 pdf_download_url: make_rel(pdf_path),
11357 json_download_url: make_rel(json_path),
11358 html_path: html_path.map(display_path),
11359 json_path: json_path.map(display_path),
11360 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
11361 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
11362 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
11363 prev_fa_str,
11364 prev_fs_str,
11365 prev_pl_str,
11366 prev_cl_str,
11367 prev_cml_str,
11368 prev_bl_str,
11369 delta_fa_str,
11370 delta_fa_class: delta_fa_class.to_string(),
11371 delta_fs_str,
11372 delta_fs_class: delta_fs_class.to_string(),
11373 delta_pl_str,
11374 delta_pl_class: delta_pl_class.to_string(),
11375 delta_cl_str,
11376 delta_cl_class: delta_cl_class.to_string(),
11377 delta_cml_str,
11378 delta_cml_class: delta_cml_class.to_string(),
11379 delta_bl_str,
11380 delta_bl_class: delta_bl_class.to_string(),
11381 delta_lines_added,
11382 delta_lines_removed,
11383 delta_lines_net_str,
11384 delta_lines_net_class,
11385 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
11386 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
11387 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
11388 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
11389 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
11390 d.file_deltas
11391 .iter()
11392 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
11393 .map(|f| {
11394 #[allow(clippy::cast_sign_loss)]
11395 let n = f.current_code as u64;
11396 n
11397 })
11398 .sum()
11399 }),
11400 git_branch: run.git_branch.clone(),
11401 git_branch_url,
11402 git_commit: run.git_commit_short.clone(),
11403 git_commit_long: run.git_commit_long.clone(),
11404 git_author: run.git_commit_author.clone(),
11405 git_commit_url,
11406 scan_performed_by,
11407 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
11408 os_display: format!(
11409 "{} / {}",
11410 run.environment.operating_system, run.environment.architecture
11411 ),
11412 test_count: run.summary_totals.test_count,
11413 current_scan_number: prev_scan_count + 1,
11414 prev_scan_count,
11415 submodule_rows,
11416 pdf_generating: false,
11417 scan_config_url: scan_config_rel,
11418 lang_chart_json,
11419 scatter_chart_json: String::new(),
11420 semantic_chart_json: String::new(),
11421 submodule_chart_json: String::new(),
11422 has_submodule_data: !run.submodule_summaries.is_empty(),
11423 has_semantic_data: run
11424 .totals_by_language
11425 .iter()
11426 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
11427 csp_nonce: String::new(),
11428 confluence_configured: false,
11429 server_mode: false,
11430 report_header_footer: run
11431 .effective_configuration
11432 .reporting
11433 .report_header_footer
11434 .clone(),
11435 is_offline: true,
11436 };
11437
11438 if let Ok(html) = template.render() {
11439 let index_path = run_dir.join("index.html");
11440 if let Err(e) = fs::write(&index_path, html) {
11441 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
11442 }
11443 }
11444}
11445
11446fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
11449 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
11451 return Some(found);
11452 }
11453 find_scan_config_in_dir_flat(dir)
11455}
11456
11457fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
11458 let exact = dir.join("scan-config.json");
11459 if exact.exists() {
11460 return Some(exact);
11461 }
11462 fs::read_dir(dir).ok().and_then(|entries| {
11463 entries
11464 .filter_map(std::result::Result::ok)
11465 .find(|e| {
11466 let name = e.file_name();
11467 let name = name.to_string_lossy();
11468 name.starts_with("scan-config") && name.ends_with(".json")
11469 })
11470 .map(|e| e.path())
11471 })
11472}
11473
11474async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
11477 let toml_str = match toml::to_string_pretty(&state.base_config) {
11478 Ok(s) => s,
11479 Err(e) => {
11480 return (
11481 StatusCode::INTERNAL_SERVER_ERROR,
11482 format!("serialization error: {e}"),
11483 )
11484 .into_response();
11485 }
11486 };
11487 (
11488 [
11489 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
11490 (
11491 header::CONTENT_DISPOSITION,
11492 "attachment; filename=\".oxide-sloc.toml\"",
11493 ),
11494 ],
11495 toml_str,
11496 )
11497 .into_response()
11498}
11499
11500#[derive(Serialize)]
11501struct OkResponse {
11502 ok: bool,
11503}
11504
11505#[derive(Serialize)]
11506struct SaveProfileResponse {
11507 ok: bool,
11508 id: String,
11509}
11510
11511#[derive(Serialize)]
11512struct ProfileListResponse {
11513 profiles: Vec<ScanProfile>,
11514}
11515
11516#[derive(Serialize)]
11517struct ImportConfigResponse {
11518 ok: bool,
11519 config: sloc_config::AppConfig,
11520}
11521
11522#[derive(Deserialize)]
11523struct ImportConfigBody {
11524 toml: String,
11525}
11526
11527async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
11528 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
11529 Ok(config) => {
11530 if let Err(e) = config.validate() {
11531 return error::unprocessable_entity(&e.to_string());
11532 }
11533 Json(ImportConfigResponse { ok: true, config }).into_response()
11534 }
11535 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
11536 }
11537}
11538
11539async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
11542 let store = state.scan_profiles.lock().await;
11543 Json(ProfileListResponse {
11544 profiles: store.profiles.clone(),
11545 })
11546}
11547
11548#[derive(Deserialize)]
11549struct SaveScanProfileBody {
11550 name: String,
11551 params: serde_json::Value,
11552}
11553
11554async fn api_save_scan_profile(
11555 State(state): State<AppState>,
11556 Json(body): Json<SaveScanProfileBody>,
11557) -> impl IntoResponse {
11558 if body.name.trim().is_empty() {
11559 return error::bad_request("name must not be empty");
11560 }
11561
11562 let id = uuid::Uuid::new_v4().to_string();
11563 let profile = ScanProfile {
11564 id: id.clone(),
11565 name: body.name.trim().to_string(),
11566 created_at: chrono::Utc::now().to_rfc3339(),
11567 params: body.params,
11568 };
11569
11570 let mut store = state.scan_profiles.lock().await;
11571 store.profiles.push(profile);
11572 if let Err(e) = store.save(&state.scan_profiles_path) {
11573 tracing::warn!("failed to persist scan profiles: {e}");
11574 }
11575 drop(store);
11576
11577 (
11578 StatusCode::CREATED,
11579 Json(SaveProfileResponse { ok: true, id }),
11580 )
11581 .into_response()
11582}
11583
11584async fn api_delete_scan_profile(
11585 State(state): State<AppState>,
11586 AxumPath(id): AxumPath<String>,
11587) -> impl IntoResponse {
11588 let mut store = state.scan_profiles.lock().await;
11589 let before = store.profiles.len();
11590 store.profiles.retain(|p| p.id != id);
11591 if store.profiles.len() == before {
11592 drop(store);
11593 return error::not_found("profile not found");
11594 }
11595 if let Err(e) = store.save(&state.scan_profiles_path) {
11596 tracing::warn!("failed to persist scan profiles: {e}");
11597 }
11598 drop(store);
11599 Json(OkResponse { ok: true }).into_response()
11600}
11601
11602fn resolve_output_root(raw: Option<&str>) -> PathBuf {
11603 let value = raw.unwrap_or("out/web").trim();
11604 let path = if value.is_empty() {
11605 PathBuf::from("out/web")
11606 } else {
11607 PathBuf::from(value)
11608 };
11609
11610 if path.is_absolute() {
11611 path
11612 } else {
11613 workspace_root().join(path)
11614 }
11615}
11616
11617fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
11619 std::env::var("SLOC_GIT_CLONES_DIR")
11620 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
11621}
11622
11623pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
11626 let safe: String = repo_url
11627 .chars()
11628 .map(|c| {
11629 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
11630 c
11631 } else {
11632 '_'
11633 }
11634 })
11635 .take(80)
11636 .collect();
11637 clones_dir.join(safe)
11638}
11639
11640pub(crate) fn scan_path_to_artifacts(
11643 scan_path: &Path,
11644 base_config: &AppConfig,
11645 label: &str,
11646) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
11647 let mut config = base_config.clone();
11648 config.discovery.root_paths = vec![scan_path.to_path_buf()];
11649 label.clone_into(&mut config.reporting.report_title);
11650 let run = analyze(&config, "git", None, None)?;
11651 let html = render_html(&run)?;
11652 let run_id = run.tool.run_id.clone();
11653 let project_label = sanitize_project_label(label);
11654 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
11655 let file_stem = {
11656 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
11657 if commit.is_empty() {
11658 project_label
11659 } else {
11660 format!("{project_label}_{commit}")
11661 }
11662 };
11663 let (artifacts, _pending_pdf) = persist_run_artifacts(
11664 &run,
11665 &html,
11666 &output_dir,
11667 label,
11668 &file_stem,
11669 RunResultContext::default(),
11670 )?;
11671 Ok((run_id, artifacts, run))
11672}
11673
11674async fn restart_poll_schedules(state: &AppState) {
11676 let store = state.schedules.lock().await;
11677 let poll_schedules: Vec<_> = store
11678 .schedules
11679 .iter()
11680 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
11681 .cloned()
11682 .collect();
11683 drop(store);
11684 for schedule in poll_schedules {
11685 let interval = schedule.interval_secs.unwrap_or(300);
11686 let st = state.clone();
11687 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
11688 }
11689}
11690
11691fn split_patterns(raw: Option<&str>) -> Vec<String> {
11692 raw.unwrap_or("")
11693 .lines()
11694 .flat_map(|line| line.split(','))
11695 .map(str::trim)
11696 .filter(|part| !part.is_empty())
11697 .map(ToOwned::to_owned)
11698 .collect()
11699}
11700
11701#[must_use]
11702pub fn build_sub_run(
11703 parent: &AnalysisRun,
11704 sub: &sloc_core::SubmoduleSummary,
11705 parent_path: &str,
11706) -> AnalysisRun {
11707 let sub_files: Vec<_> = parent
11708 .per_file_records
11709 .iter()
11710 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
11711 .cloned()
11712 .collect();
11713 let mut config = parent.effective_configuration.clone();
11714 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
11715
11716 let mut functions = 0u64;
11718 let mut classes = 0u64;
11719 let mut variables = 0u64;
11720 let mut imports = 0u64;
11721 let mut test_count = 0u64;
11722 let mut test_assertion_count = 0u64;
11723 let mut test_suite_count = 0u64;
11724 let mut mixed_lines_separate = 0u64;
11725 let mut coverage_lines_found = 0u64;
11726 let mut coverage_lines_hit = 0u64;
11727 let mut coverage_functions_found = 0u64;
11728 let mut coverage_functions_hit = 0u64;
11729 let mut coverage_branches_found = 0u64;
11730 let mut coverage_branches_hit = 0u64;
11731 for r in &sub_files {
11732 functions += r.raw_line_categories.functions;
11733 classes += r.raw_line_categories.classes;
11734 variables += r.raw_line_categories.variables;
11735 imports += r.raw_line_categories.imports;
11736 test_count += r.raw_line_categories.test_count;
11737 test_assertion_count += r.raw_line_categories.test_assertion_count;
11738 test_suite_count += r.raw_line_categories.test_suite_count;
11739 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
11740 if let Some(cov) = &r.coverage {
11741 coverage_lines_found += u64::from(cov.lines_found);
11742 coverage_lines_hit += u64::from(cov.lines_hit);
11743 coverage_functions_found += u64::from(cov.functions_found);
11744 coverage_functions_hit += u64::from(cov.functions_hit);
11745 coverage_branches_found += u64::from(cov.branches_found);
11746 coverage_branches_hit += u64::from(cov.branches_hit);
11747 }
11748 }
11749
11750 AnalysisRun {
11751 tool: parent.tool.clone(),
11752 environment: parent.environment.clone(),
11753 effective_configuration: config,
11754 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
11755 summary_totals: SummaryTotals {
11756 files_considered: sub.files_analyzed,
11757 files_analyzed: sub.files_analyzed,
11758 files_skipped: 0,
11759 total_physical_lines: sub.total_physical_lines,
11760 code_lines: sub.code_lines,
11761 comment_lines: sub.comment_lines,
11762 blank_lines: sub.blank_lines,
11763 mixed_lines_separate,
11764 functions,
11765 classes,
11766 variables,
11767 imports,
11768 test_count,
11769 test_assertion_count,
11770 test_suite_count,
11771 coverage_lines_found,
11772 coverage_lines_hit,
11773 coverage_functions_found,
11774 coverage_functions_hit,
11775 coverage_branches_found,
11776 coverage_branches_hit,
11777 },
11778 totals_by_language: sub.language_summaries.clone(),
11779 per_file_records: sub_files,
11780 skipped_file_records: vec![],
11781 warnings: vec![],
11782 submodule_summaries: vec![],
11783 git_commit_short: sub.git_commit_short.clone(),
11784 git_commit_long: sub.git_commit_long.clone(),
11785 git_branch: sub.git_branch.clone(),
11786 git_commit_author: sub.git_commit_author.clone(),
11787 git_commit_date: sub.git_commit_date.clone(),
11788 git_tags: None,
11789 git_nearest_tag: None,
11790 git_remote_url: sub.git_remote_url.clone(),
11791 style_summary: None,
11792 }
11793}
11794
11795#[must_use]
11796pub fn sanitize_project_label(raw: &str) -> String {
11797 let candidate = Path::new(raw)
11798 .file_name()
11799 .and_then(|name| name.to_str())
11800 .unwrap_or("project");
11801
11802 let mut value = String::with_capacity(candidate.len());
11803 for ch in candidate.chars() {
11804 if ch.is_ascii_alphanumeric() {
11805 value.push(ch.to_ascii_lowercase());
11806 } else {
11807 value.push('-');
11808 }
11809 }
11810
11811 let compact = value.trim_matches('-').to_string();
11812 if compact.is_empty() {
11813 "project".to_string()
11814 } else {
11815 compact
11816 }
11817}
11818
11819fn strip_unc_prefix(path: PathBuf) -> PathBuf {
11822 let s = path.to_string_lossy();
11823 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11824 return PathBuf::from(format!(r"\\{rest}"));
11825 }
11826 if let Some(rest) = s.strip_prefix(r"\\?\") {
11827 return PathBuf::from(rest);
11828 }
11829 path
11830}
11831
11832fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
11835 let base = if let Some(rest) = remote.strip_prefix("git@") {
11836 let (host, path) = rest.split_once(':')?;
11837 format!("https://{}/{}", host, path.trim_end_matches(".git"))
11838 } else if remote.starts_with("https://") || remote.starts_with("http://") {
11839 remote
11840 .trim_end_matches('/')
11841 .trim_end_matches(".git")
11842 .to_owned()
11843 } else {
11844 return None;
11845 };
11846 let base = base.trim_end_matches('/');
11847 if base.contains("gitlab.com") || base.contains("gitlab.") {
11849 Some(format!("{base}/-/commit/{sha}"))
11850 } else if base.contains("bitbucket.org") {
11851 Some(format!("{base}/commits/{sha}"))
11852 } else {
11853 Some(format!("{base}/commit/{sha}"))
11854 }
11855}
11856
11857fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
11860 let base = if let Some(rest) = remote.strip_prefix("git@") {
11861 let (host, path) = rest.split_once(':')?;
11862 format!("https://{}/{}", host, path.trim_end_matches(".git"))
11863 } else if remote.starts_with("https://") || remote.starts_with("http://") {
11864 remote
11865 .trim_end_matches('/')
11866 .trim_end_matches(".git")
11867 .to_owned()
11868 } else {
11869 return None;
11870 };
11871 let base = base.trim_end_matches('/');
11872 if base.contains("gitlab.com") || base.contains("gitlab.") {
11873 Some(format!("{base}/-/tree/{branch}"))
11874 } else {
11875 Some(format!("{base}/tree/{branch}"))
11876 }
11877}
11878
11879fn display_path(path: &Path) -> String {
11880 let s = path.to_string_lossy();
11881 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11886 return format!(r"\\{rest}");
11887 }
11888 if let Some(rest) = s.strip_prefix(r"\\?\") {
11889 return rest.to_owned();
11890 }
11891 s.into_owned()
11892}
11893
11894fn sanitize_path_str(s: &str) -> String {
11895 if let Some(rest) = s.strip_prefix("//?/UNC/") {
11899 return format!("//{rest}");
11900 }
11901 if let Some(rest) = s.strip_prefix("//?/") {
11902 return rest.to_owned();
11903 }
11904 display_path(Path::new(s))
11905}
11906
11907fn workspace_root() -> PathBuf {
11908 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
11910 let p = PathBuf::from(root);
11911 if p.is_dir() {
11912 return p;
11913 }
11914 }
11915
11916 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
11919}
11920
11921fn make_git_label(repo: &str, ref_name: &str) -> String {
11923 if repo.is_empty() || ref_name.is_empty() {
11924 return String::new();
11925 }
11926 let base = repo
11927 .trim_end_matches('/')
11928 .trim_end_matches(".git")
11929 .rsplit('/')
11930 .next()
11931 .unwrap_or("repo");
11932 let ref_safe: String = ref_name
11933 .chars()
11934 .map(|c| {
11935 if c.is_alphanumeric() || c == '-' || c == '.' {
11936 c
11937 } else {
11938 '_'
11939 }
11940 })
11941 .collect();
11942 format!("{base}_at_{ref_safe}_sloc")
11943}
11944
11945fn desktop_dir() -> PathBuf {
11947 if let Ok(profile) = std::env::var("USERPROFILE") {
11948 let p = PathBuf::from(profile).join("Desktop");
11949 if p.exists() {
11950 return p;
11951 }
11952 }
11953 if let Ok(home) = std::env::var("HOME") {
11954 let p = PathBuf::from(home).join("Desktop");
11955 if p.exists() {
11956 return p;
11957 }
11958 }
11959 workspace_root().join("out").join("web")
11960}
11961
11962fn resolve_input_path(raw: &str) -> PathBuf {
11963 let trimmed = raw.trim();
11964 if trimmed.is_empty() {
11965 return workspace_root().join("samples").join("basic");
11966 }
11967
11968 let candidate = PathBuf::from(trimmed);
11969 let resolved = if candidate.is_absolute() {
11970 candidate
11971 } else {
11972 let rooted = workspace_root().join(&candidate);
11973 if rooted.exists() {
11974 rooted
11975 } else {
11976 workspace_root().join(candidate)
11977 }
11978 };
11979
11980 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
11983 PathBuf::from(display_path(&canonical))
11984}
11985
11986fn dir_size_bytes(path: &Path) -> u64 {
11987 let mut total = 0u64;
11988 if let Ok(rd) = fs::read_dir(path) {
11989 for entry in rd.filter_map(Result::ok) {
11990 let p = entry.path();
11991 if p.is_file() {
11992 if let Ok(meta) = p.metadata() {
11993 total += meta.len();
11994 }
11995 } else if p.is_dir() {
11996 total += dir_size_bytes(&p);
11997 }
11998 }
11999 }
12000 total
12001}
12002
12003#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
12005 if bytes >= 1_073_741_824 {
12006 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
12007 } else if bytes >= 1_048_576 {
12008 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
12009 } else if bytes >= 1_024 {
12010 format!("{:.0} KB", bytes as f64 / 1_024.0)
12011 } else {
12012 format!("{bytes} B")
12013 }
12014}
12015
12016fn render_submodule_chips(
12017 root: &Path,
12018 submodules: &[(String, std::path::PathBuf)],
12019 out: &mut String,
12020) {
12021 use std::fmt::Write as _;
12022 let count = submodules.len();
12023 out.push_str(r#"<div class="submodule-preview-strip">"#);
12024 write!(
12025 out,
12026 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>"#,
12027 if count == 1 { "" } else { "s" }
12028 )
12029 .ok();
12030 out.push_str(r#"<div class="submodule-preview-chips">"#);
12031 for (sub_name, sub_rel_path) in submodules {
12032 let sub_abs = root.join(sub_rel_path);
12033 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
12034 let mut sub_stats = PreviewStats::default();
12035 let mut sub_rows: Vec<PreviewRow> = Vec::new();
12036 let mut sub_langs: Vec<&'static str> = Vec::new();
12037 let mut sub_budget = PreviewBudget {
12038 shown: 0,
12039 max_entries: 2000,
12040 max_depth: 9,
12041 };
12042 let mut sub_next_id = 1usize;
12043 let _ = collect_preview_rows(
12044 &sub_abs,
12045 &sub_abs,
12046 0,
12047 None,
12048 &mut sub_next_id,
12049 &mut sub_budget,
12050 &mut sub_stats,
12051 &mut sub_rows,
12052 &mut sub_langs,
12053 &[],
12054 &[],
12055 );
12056 let stats_json = format!(
12057 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
12058 sub_stats.directories,
12059 sub_stats.files,
12060 sub_stats.supported,
12061 sub_stats.skipped,
12062 sub_stats.unsupported
12063 );
12064 write!(
12065 out,
12066 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>"#,
12067 escape_html(sub_name),
12068 escape_html(&sub_rel_path.to_string_lossy()),
12069 escape_html(&sub_size),
12070 escape_html(&stats_json),
12071 escape_html(sub_name),
12072 escape_html(&sub_size),
12073 )
12074 .ok();
12075 }
12076 out.push_str(
12077 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
12078 );
12079 out.push_str(r"</div>");
12080}
12081
12082fn render_language_pills_row(languages: &[&str], out: &mut String) {
12083 use std::fmt::Write as _;
12084 if languages.is_empty() {
12085 out.push_str(
12086 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
12087 );
12088 return;
12089 }
12090 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
12091 for language in languages {
12092 if let Some(icon) = language_icon_file(language) {
12093 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();
12094 } else if let Some(svg) = language_inline_svg(language) {
12095 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();
12096 } else {
12097 write!(
12098 out,
12099 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
12100 escape_html(&language.to_ascii_lowercase()),
12101 escape_html(language)
12102 )
12103 .ok();
12104 }
12105 }
12106}
12107
12108#[allow(clippy::too_many_lines)]
12109fn build_preview_html(
12110 root: &Path,
12111 include_patterns: &[String],
12112 exclude_patterns: &[String],
12113) -> Result<String> {
12114 if !root.exists() {
12115 return Ok(format!(
12116 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
12117 escape_html(&display_path(root))
12118 ));
12119 }
12120
12121 let _selected = display_path(root);
12122 let mut stats = PreviewStats::default();
12123 let mut rows = Vec::new();
12124 let mut languages = Vec::new();
12125 let mut budget = PreviewBudget {
12126 shown: 0,
12127 max_entries: 600,
12128 max_depth: 9,
12129 };
12130 let mut next_row_id = 1usize;
12131
12132 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
12133 || root.to_string_lossy().into_owned(),
12134 std::string::ToString::to_string,
12135 );
12136 let root_modified = root
12137 .metadata()
12138 .ok()
12139 .and_then(|meta| meta.modified().ok())
12140 .map_or_else(|| "-".to_string(), format_system_time);
12141
12142 rows.push(PreviewRow {
12143 row_id: 0,
12144 parent_row_id: None,
12145 depth: 0,
12146 name: format!("{root_name}/"),
12147 kind: PreviewKind::Dir,
12148 is_dir: true,
12149 language: None,
12150 modified: root_modified,
12151 type_label: "Directory".to_string(),
12152 });
12153 collect_preview_rows(
12154 root,
12155 root,
12156 0,
12157 Some(0),
12158 &mut next_row_id,
12159 &mut budget,
12160 &mut stats,
12161 &mut rows,
12162 &mut languages,
12163 include_patterns,
12164 exclude_patterns,
12165 )?;
12166
12167 let root_size = format_dir_size(dir_size_bytes(root));
12168
12169 let mut out = String::new();
12170 write!(
12171 out,
12172 r#"<div class="explorer-wrap" data-project-size="{}">"#,
12173 escape_html(&root_size)
12174 )
12175 .ok();
12176 out.push_str(r#"<div class="explorer-toolbar compact">"#);
12177 out.push_str(r#"<div class="explorer-title-group">"#);
12178 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
12179 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
12180 out.push_str(r"</div></div>");
12181
12182 out.push_str(r#"<div class="scope-stats">"#);
12183 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();
12184 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();
12185 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();
12186 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();
12187 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();
12188 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>"#);
12189 out.push_str(r"</div>");
12190
12191 let submodules = sloc_core::detect_submodules(root);
12192 if !submodules.is_empty() {
12193 render_submodule_chips(root, &submodules, &mut out);
12194 }
12195
12196 out.push_str(r#"<div class="scope-info-row">"#);
12197 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
12198 render_language_pills_row(&languages, &mut out);
12199 out.push_str(r"</div></div>");
12200 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>"#);
12201 out.push_str(r"</div>");
12202
12203 out.push_str(r#"<div class="file-explorer-shell">"#);
12204 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>"#);
12205 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>"#);
12206 out.push_str(r#"<div class="file-explorer-tree">"#);
12207 for row in rows {
12208 let status_label = row.kind.label();
12209 let lang_attr = row.language.unwrap_or("");
12210 let toggle_html = if row.is_dir {
12211 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
12212 .to_string()
12213 } else {
12214 r#"<span class="tree-bullet">•</span>"#.to_string()
12215 };
12216 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();
12217 }
12218 if budget.shown >= budget.max_entries {
12219 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>"#);
12220 }
12221 out.push_str(r"</div></div></div>");
12222
12223 Ok(out)
12224}
12225
12226#[derive(Default)]
12227struct PreviewStats {
12228 directories: usize,
12229 files: usize,
12230 supported: usize,
12231 skipped: usize,
12232 unsupported: usize,
12233}
12234
12235struct PreviewRow {
12236 row_id: usize,
12237 parent_row_id: Option<usize>,
12238 depth: usize,
12239 name: String,
12240 kind: PreviewKind,
12241 is_dir: bool,
12242 language: Option<&'static str>,
12243 modified: String,
12244 type_label: String,
12245}
12246
12247#[derive(Copy, Clone)]
12248enum PreviewKind {
12249 Dir,
12250 Supported,
12251 Skipped,
12252 Unsupported,
12253}
12254
12255impl PreviewKind {
12256 const fn filter_key(self) -> &'static str {
12257 match self {
12258 Self::Dir => "dir",
12259 Self::Supported => "supported",
12260 Self::Skipped => "skipped",
12261 Self::Unsupported => "unsupported",
12262 }
12263 }
12264
12265 const fn label(self) -> &'static str {
12266 match self {
12267 Self::Dir => "dir",
12268 Self::Supported => "supported",
12269 Self::Skipped => "skipped by policy",
12270 Self::Unsupported => "unsupported",
12271 }
12272 }
12273
12274 const fn badge_class(self) -> &'static str {
12275 match self {
12276 Self::Dir => "badge badge-dir",
12277 Self::Supported => "badge badge-scan",
12278 Self::Skipped => "badge badge-skip",
12279 Self::Unsupported => "badge badge-unsupported",
12280 }
12281 }
12282
12283 const fn node_class(self) -> &'static str {
12284 match self {
12285 Self::Dir => "tree-node-dir",
12286 Self::Supported => "tree-node-supported",
12287 Self::Skipped => "tree-node-skipped",
12288 Self::Unsupported => "tree-node-unsupported",
12289 }
12290 }
12291}
12292
12293struct PreviewBudget {
12294 shown: usize,
12295 max_entries: usize,
12296 max_depth: usize,
12297}
12298
12299#[allow(clippy::too_many_arguments)]
12302fn handle_preview_dir_entry(
12303 root: &Path,
12304 path: &Path,
12305 name: &str,
12306 modified: String,
12307 depth: usize,
12308 parent_row_id: Option<usize>,
12309 row_id: usize,
12310 next_row_id: &mut usize,
12311 budget: &mut PreviewBudget,
12312 stats: &mut PreviewStats,
12313 rows: &mut Vec<PreviewRow>,
12314 languages: &mut Vec<&'static str>,
12315 include_patterns: &[String],
12316 exclude_patterns: &[String],
12317) -> Result<()> {
12318 let relative = preview_relative_path(root, path);
12319 if should_skip_preview_directory(&relative, exclude_patterns) {
12320 return Ok(());
12321 }
12322 stats.directories += 1;
12323 rows.push(PreviewRow {
12324 row_id,
12325 parent_row_id,
12326 depth: depth + 1,
12327 name: format!("{name}/"),
12328 kind: PreviewKind::Dir,
12329 is_dir: true,
12330 language: None,
12331 modified,
12332 type_label: "Directory".to_string(),
12333 });
12334 budget.shown += 1;
12335 if !matches!(name, ".git" | "node_modules" | "target") {
12336 collect_preview_rows(
12337 root,
12338 path,
12339 depth + 1,
12340 Some(row_id),
12341 next_row_id,
12342 budget,
12343 stats,
12344 rows,
12345 languages,
12346 include_patterns,
12347 exclude_patterns,
12348 )?;
12349 }
12350 Ok(())
12351}
12352
12353#[allow(clippy::too_many_arguments)]
12355fn handle_preview_file_entry(
12356 root: &Path,
12357 path: &Path,
12358 name: &str,
12359 modified: String,
12360 depth: usize,
12361 parent_row_id: Option<usize>,
12362 row_id: usize,
12363 budget: &mut PreviewBudget,
12364 stats: &mut PreviewStats,
12365 rows: &mut Vec<PreviewRow>,
12366 languages: &mut Vec<&'static str>,
12367 include_patterns: &[String],
12368 exclude_patterns: &[String],
12369) {
12370 let relative = preview_relative_path(root, path);
12371 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
12372 return;
12373 }
12374 stats.files += 1;
12375 let kind = classify_preview_file(name);
12376 match kind {
12377 PreviewKind::Supported => stats.supported += 1,
12378 PreviewKind::Skipped => stats.skipped += 1,
12379 PreviewKind::Unsupported => stats.unsupported += 1,
12380 PreviewKind::Dir => {}
12381 }
12382 let language = detect_language_name(name);
12383 if let Some(lang) = language {
12384 if !languages.contains(&lang) {
12385 languages.push(lang);
12386 }
12387 }
12388 rows.push(PreviewRow {
12389 row_id,
12390 parent_row_id,
12391 depth: depth + 1,
12392 name: name.to_owned(),
12393 kind,
12394 is_dir: false,
12395 language,
12396 modified,
12397 type_label: preview_type_label(name, language, kind),
12398 });
12399 budget.shown += 1;
12400}
12401
12402#[allow(clippy::too_many_arguments)]
12403#[allow(clippy::too_many_lines)]
12404fn collect_preview_rows(
12405 root: &Path,
12406 dir: &Path,
12407 depth: usize,
12408 parent_row_id: Option<usize>,
12409 next_row_id: &mut usize,
12410 budget: &mut PreviewBudget,
12411 stats: &mut PreviewStats,
12412 rows: &mut Vec<PreviewRow>,
12413 languages: &mut Vec<&'static str>,
12414 include_patterns: &[String],
12415 exclude_patterns: &[String],
12416) -> Result<()> {
12417 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
12418 return Ok(());
12419 }
12420
12421 let mut entries = fs::read_dir(dir)
12422 .with_context(|| format!("failed to read directory {}", dir.display()))?
12423 .filter_map(std::result::Result::ok)
12424 .collect::<Vec<_>>();
12425 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
12426
12427 for entry in entries {
12428 if budget.shown >= budget.max_entries {
12429 break;
12430 }
12431
12432 let path = entry.path();
12433 let name = entry.file_name().to_string_lossy().into_owned();
12434 let Ok(metadata) = entry.metadata() else {
12435 continue;
12436 };
12437 let row_id = *next_row_id;
12438 *next_row_id += 1;
12439 let modified = metadata
12440 .modified()
12441 .ok()
12442 .map_or_else(|| "-".to_string(), format_system_time);
12443
12444 if metadata.is_dir() {
12445 handle_preview_dir_entry(
12446 root,
12447 &path,
12448 &name,
12449 modified,
12450 depth,
12451 parent_row_id,
12452 row_id,
12453 next_row_id,
12454 budget,
12455 stats,
12456 rows,
12457 languages,
12458 include_patterns,
12459 exclude_patterns,
12460 )?;
12461 continue;
12462 }
12463
12464 if metadata.is_file() {
12465 handle_preview_file_entry(
12466 root,
12467 &path,
12468 &name,
12469 modified,
12470 depth,
12471 parent_row_id,
12472 row_id,
12473 budget,
12474 stats,
12475 rows,
12476 languages,
12477 include_patterns,
12478 exclude_patterns,
12479 );
12480 }
12481 }
12482
12483 Ok(())
12484}
12485
12486fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
12487 if let Some(language) = language {
12488 return format!("{language} source");
12489 }
12490 let lower = name.to_ascii_lowercase();
12491 let ext = Path::new(&lower)
12492 .extension()
12493 .and_then(|e| e.to_str())
12494 .unwrap_or("");
12495 match kind {
12496 PreviewKind::Skipped => {
12497 if lower.ends_with(".min.js") {
12498 "Minified asset".to_string()
12499 } else if [
12500 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
12501 ]
12502 .contains(&ext)
12503 {
12504 "Binary or archive".to_string()
12505 } else {
12506 "Skipped file".to_string()
12507 }
12508 }
12509 PreviewKind::Unsupported => {
12510 if ext.is_empty() {
12511 "Unsupported file".to_string()
12512 } else {
12513 format!("{} file", ext.to_ascii_uppercase())
12514 }
12515 }
12516 PreviewKind::Supported => "Supported source".to_string(),
12517 PreviewKind::Dir => "Directory".to_string(),
12518 }
12519}
12520
12521fn format_system_time(time: SystemTime) -> String {
12522 #[allow(clippy::cast_possible_wrap)]
12523 let secs = match time.duration_since(UNIX_EPOCH) {
12524 Ok(duration) => duration.as_secs() as i64,
12525 Err(_) => return "-".to_string(),
12526 };
12527 let days = secs.div_euclid(86_400);
12528 let secs_of_day = secs.rem_euclid(86_400);
12529 let (year, month, day) = civil_from_days(days);
12530 let hour = secs_of_day / 3_600;
12531 let minute = (secs_of_day % 3_600) / 60;
12532 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
12533}
12534
12535#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
12536fn civil_from_days(days: i64) -> (i32, u32, u32) {
12537 let z = days + 719_468;
12538 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
12539 let doe = z - era * 146_097;
12540 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
12541 let y = yoe + era * 400;
12542 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
12543 let mp = (5 * doy + 2) / 153;
12544 let d = doy - (153 * mp + 2) / 5 + 1;
12545 let m = mp + if mp < 10 { 3 } else { -9 };
12546 let year = y + i64::from(m <= 2);
12547 (year as i32, m as u32, d as u32)
12548}
12549
12550#[allow(clippy::case_sensitive_file_extension_comparisons)]
12553fn detect_language_name(name: &str) -> Option<&'static str> {
12554 let lower = name.to_ascii_lowercase();
12555 if lower.ends_with(".c") || lower.ends_with(".h") {
12556 Some("C")
12557 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
12558 .iter()
12559 .any(|s| lower.ends_with(s))
12560 {
12561 Some("C++")
12562 } else if lower.ends_with(".cs") {
12563 Some("C#")
12564 } else if lower.ends_with(".py") {
12565 Some("Python")
12566 } else if lower.ends_with(".sh") {
12567 Some("Shell")
12568 } else if [".ps1", ".psm1", ".psd1"]
12569 .iter()
12570 .any(|s| lower.ends_with(s))
12571 {
12572 Some("PowerShell")
12573 } else {
12574 None
12575 }
12576}
12577
12578fn language_icon_file(language: &str) -> Option<&'static str> {
12579 match language {
12580 "C" => Some("c.png"),
12581 "C++" => Some("cpp.png"),
12582 "C#" => Some("c-sharp.png"),
12583 "Python" => Some("python.png"),
12584 "Shell" => Some("shell.png"),
12585 "PowerShell" => Some("powershell.png"),
12586 "JavaScript" => Some("java-script.png"),
12587 "HTML" => Some("html-5.png"),
12588 "Java" => Some("java.png"),
12589 "Visual Basic" => Some("visual-basic.png"),
12590 "Assembly" => Some("asm.png"),
12591 "Go" => Some("go.png"),
12592 "R" => Some("r.png"),
12593 "XML" => Some("xml.png"),
12594 "Groovy" => Some("groovy.png"),
12595 "Dockerfile" => Some("docker.png"),
12596 "Makefile" => Some("makefile.svg"),
12597 "Perl" => Some("perl.svg"),
12598 _ => None,
12599 }
12600}
12601
12602fn language_inline_svg(language: &str) -> Option<&'static str> {
12607 match language {
12608 "Rust" => Some(
12609 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>"##,
12610 ),
12611 "TypeScript" => Some(
12612 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>"##,
12613 ),
12614 _ => None,
12615 }
12616}
12617
12618#[allow(clippy::case_sensitive_file_extension_comparisons)]
12621fn classify_preview_file(name: &str) -> PreviewKind {
12622 let lower = name.to_ascii_lowercase();
12623
12624 let scannable = [
12625 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
12626 ".psm1", ".psd1",
12627 ]
12628 .iter()
12629 .any(|suffix| lower.ends_with(suffix));
12630
12631 if scannable {
12632 PreviewKind::Supported
12633 } else if lower.ends_with(".min.js")
12634 || lower.ends_with(".lock")
12635 || lower.ends_with(".png")
12636 || lower.ends_with(".jpg")
12637 || lower.ends_with(".jpeg")
12638 || lower.ends_with(".gif")
12639 || lower.ends_with(".zip")
12640 || lower.ends_with(".pdf")
12641 || lower.ends_with(".pyc")
12642 || lower.ends_with(".xz")
12643 || lower.ends_with(".tar")
12644 || lower.ends_with(".gz")
12645 {
12646 PreviewKind::Skipped
12647 } else {
12648 PreviewKind::Unsupported
12649 }
12650}
12651
12652fn preview_relative_path(root: &Path, path: &Path) -> String {
12653 path.strip_prefix(root)
12654 .ok()
12655 .unwrap_or(path)
12656 .to_string_lossy()
12657 .replace('\\', "/")
12658 .trim_matches('/')
12659 .to_string()
12660}
12661
12662fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
12663 if relative.is_empty() {
12664 return false;
12665 }
12666
12667 exclude_patterns.iter().any(|pattern| {
12668 wildcard_match(pattern, relative)
12669 || wildcard_match(pattern, &format!("{relative}/"))
12670 || wildcard_match(pattern, &format!("{relative}/placeholder"))
12671 })
12672}
12673
12674fn should_include_preview_file(
12675 relative: &str,
12676 include_patterns: &[String],
12677 exclude_patterns: &[String],
12678) -> bool {
12679 if relative.is_empty() {
12680 return true;
12681 }
12682
12683 let included = include_patterns.is_empty()
12684 || include_patterns
12685 .iter()
12686 .any(|pattern| wildcard_match(pattern, relative));
12687 let excluded = exclude_patterns
12688 .iter()
12689 .any(|pattern| wildcard_match(pattern, relative));
12690
12691 included && !excluded
12692}
12693
12694fn wildcard_match(pattern: &str, candidate: &str) -> bool {
12695 let pattern = pattern.trim().replace('\\', "/");
12696 let candidate = candidate.trim().replace('\\', "/");
12697 let p = pattern.as_bytes();
12698 let c = candidate.as_bytes();
12699 let mut pi = 0usize;
12700 let mut ci = 0usize;
12701 let mut star: Option<usize> = None;
12702 let mut star_match = 0usize;
12703
12704 while ci < c.len() {
12705 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
12706 pi += 1;
12707 ci += 1;
12708 } else if pi < p.len() && p[pi] == b'*' {
12709 while pi < p.len() && p[pi] == b'*' {
12710 pi += 1;
12711 }
12712 star = Some(pi);
12713 star_match = ci;
12714 } else if let Some(star_pi) = star {
12715 star_match += 1;
12716 ci = star_match;
12717 pi = star_pi;
12718 } else {
12719 return false;
12720 }
12721 }
12722
12723 while pi < p.len() && p[pi] == b'*' {
12724 pi += 1;
12725 }
12726
12727 pi == p.len()
12728}
12729
12730fn escape_html(value: &str) -> String {
12731 value
12732 .replace('&', "&")
12733 .replace('<', "<")
12734 .replace('>', ">")
12735 .replace('"', """)
12736 .replace('\'', "'")
12737}
12738
12739#[derive(Clone)]
12740struct SubmoduleRow {
12741 name: String,
12742 relative_path: String,
12743 files_analyzed: u64,
12744 code_lines: u64,
12745 comment_lines: u64,
12746 blank_lines: u64,
12747 total_physical_lines: u64,
12748 html_url: Option<String>,
12749}
12750
12751#[derive(Template)]
12752#[template(
12753 source = r##"
12754<!doctype html>
12755<html lang="en">
12756<head>
12757 <meta charset="utf-8">
12758 <title>OxideSLOC | tmp-sloc</title>
12759 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12760 <style nonce="{{ csp_nonce }}">
12761 :root {
12762 --bg: #efe9e2;
12763 --surface: #fcfaf7;
12764 --surface-2: #f7f0e8;
12765 --surface-3: #efe3d5;
12766 --line: #dfcfbf;
12767 --line-strong: #cfb29c;
12768 --text: #2f241c;
12769 --muted: #6f6257;
12770 --muted-2: #917f71;
12771 --nav: #b85d33;
12772 --nav-2: #7a371b;
12773 --accent: #2563eb;
12774 --accent-2: #1d4ed8;
12775 --oxide: #b85d33;
12776 --oxide-2: #8f4220;
12777 --success-bg: #eaf9ee;
12778 --success-text: #1c8746;
12779 --warn-bg: #fff2d8;
12780 --warn-text: #926000;
12781 --danger-bg: #fdeaea;
12782 --danger-text: #b33b3b;
12783 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
12784 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
12785 --radius: 14px;
12786 }
12787
12788 body.dark-theme {
12789 --bg: #1b1511;
12790 --surface: #261c17;
12791 --surface-2: #2d221d;
12792 --surface-3: #372922;
12793 --line: #524238;
12794 --line-strong: #6c5649;
12795 --text: #f5ece6;
12796 --muted: #c7b7aa;
12797 --muted-2: #aa9485;
12798 --nav: #b85d33;
12799 --nav-2: #7a371b;
12800 --accent: #6f9bff;
12801 --accent-2: #4a78ee;
12802 --oxide: #d37a4c;
12803 --oxide-2: #b35428;
12804 --success-bg: #163927;
12805 --success-text: #8fe2a8;
12806 --warn-bg: #3c2d11;
12807 --warn-text: #f3cb75;
12808 --danger-bg: #3d1f1f;
12809 --danger-text: #ff9f9f;
12810 --shadow: 0 14px 28px rgba(0,0,0,0.28);
12811 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
12812 }
12813
12814 * { box-sizing: border-box; }
12815 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); }
12816 html { overflow-y: scroll; }
12817 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
12818 .top-nav, .page, .loading { position: relative; z-index: 2; }
12819 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
12820 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
12821 .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); }
12822 .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; }
12823 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
12824 .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)); }
12825 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
12826 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
12827 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
12828 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
12829 .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; }
12830 .nav-project-pill.visible { display:inline-flex; }
12831 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
12832 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
12833 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
12834 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12835 @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; } }
12836 .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; }
12837 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
12838 .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; }
12839 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
12840 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
12841 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
12842 .theme-toggle .icon-sun { display:none; }
12843 body.dark-theme .theme-toggle .icon-sun { display:block; }
12844 body.dark-theme .theme-toggle .icon-moon { display:none; }
12845 .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;}
12846 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12847 .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);}
12848 .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;}
12849 .settings-close:hover{color:var(--text);background:var(--surface-2);}
12850 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12851 .settings-modal-body{padding:14px 16px 16px;}
12852 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12853 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12854 .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;}
12855 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12856 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12857 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12858 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12859 .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;}
12860 .tz-select:focus{border-color:var(--oxide);}
12861 .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; }
12862 .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;}
12863 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
12864 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
12865 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
12866 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
12867 .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; }
12868 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
12869 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
12870 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
12871 .wb-stats-header { padding: 10px 24px 0; }
12872 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
12873 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
12874 .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; }
12875 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12876 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12877 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12878 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
12879 .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; }
12880 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
12881 .ws-stat-analyzers { position: relative; }
12882 .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; }
12883 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
12884 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
12885 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
12886 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
12887 .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; }
12888 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
12889 .ws-divider { display: none; }
12890 .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%; }
12891 .ws-path-link:hover { color:var(--oxide); }
12892 body.dark-theme .ws-path-link { color:var(--oxide); }
12893 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
12894 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12895 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
12896 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12897 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
12898 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
12899 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
12900 .ws-mini-box-lg { flex:2 1 0; }
12901 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
12902 .ws-mini-box-br { flex:1.5 1 0; }
12903 .scope-legend-row { display:inline-flex; flex-direction:row; align-items:center; flex-wrap:wrap; gap:6px; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:13px; flex-shrink:0; border-left:3px solid var(--line-strong); }
12904 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
12905 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
12906 #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; }
12907 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
12908 .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; }
12909 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
12910 .git-source-banner strong { font-weight:800; color:var(--text); }
12911 .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; }
12912 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
12913 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
12914 .git-source-banner a:hover { text-decoration:underline; }
12915 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
12916 .path-scope-sep { background:var(--line); margin:4px 14px; }
12917 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
12918 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
12919 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
12920 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
12921 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
12922 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
12923 .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; }
12924 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12925 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12926 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12927 .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; }
12928 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
12929 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
12930 [data-wb-tip] { cursor:help; }
12931 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
12932 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
12933 .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; }
12934 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
12935 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
12936 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
12937 .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; }
12938 .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); }
12939 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
12940 .side-info-card { padding: 18px; }
12941 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
12942 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
12943 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
12944 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
12945 .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); }
12946 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
12947 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
12948 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
12949 .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; }
12950 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
12951 .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; }
12952 .side-stack::-webkit-scrollbar { display: none; }
12953 .step-nav { padding: 20px 16px; }
12954 .step-nav h3 { margin: 6px 4px 20px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
12955 .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; }
12956 .step-button:hover { background: var(--surface-2); }
12957 .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); }
12958 .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; }
12959 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
12960 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
12961 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
12962 .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); }
12963 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
12964 .step-nav-sum-row:last-child { border-bottom:none; }
12965 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
12966 .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; }
12967 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
12968 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
12969 .quick-scan-section { padding: 10px 4px 14px; }
12970 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
12971 .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; }
12972 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
12973 .quick-scan-btn:active { transform:translateY(0); }
12974 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
12975 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
12976 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
12977 @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);} }
12978 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
12979 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
12980 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
12981 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
12982 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
12983 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
12984 .step-button.done .step-check { opacity:1; }
12985 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
12986 .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; }
12987 .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; }
12988 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
12989 .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; }
12990 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
12991 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
12992 .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; }
12993 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
12994 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
12995 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
12996 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
12997 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
12998 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
12999 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
13000 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
13001 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
13002 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
13003 .card-body { padding: 22px; }
13004 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
13005 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
13006 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
13007 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
13008 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
13009 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
13010 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
13011 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
13012 .field { min-width:0; }
13013 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
13014 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; }
13015 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); }
13016 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
13017 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); }
13018 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
13019 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
13020 .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; }
13021 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
13022 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
13023 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
13024 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
13025 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
13026 .input-group.compact { grid-template-columns: 1fr auto auto; }
13027 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
13028 .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)); }
13029 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
13030 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
13031 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
13032 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
13033 .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; }
13034 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
13035 .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; }
13036 .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); }
13037 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
13038 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
13039 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
13040 button.secondary { background: var(--surface); }
13041 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
13042 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
13043 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
13044 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
13045 .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); }
13046 .section + .wizard-actions { border-top: none; padding-top: 0; }
13047 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
13048 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
13049 .field-help-grid.coupled-help { margin-top: 12px; }
13050 .field-help-grid.preset-grid { align-items: start; }
13051 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
13052 .preset-inline-row .field { margin: 0; }
13053 .preset-inline-row .explainer-card { margin: 0; }
13054 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
13055 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
13056 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
13057 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
13058 .preset-kv-row > :last-child { flex:1; min-width:0; }
13059 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
13060 .output-field-row .field { margin: 0; }
13061 .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; }
13062 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
13063 .step3-subtitle { margin-bottom: 10px; max-width: none; }
13064 .counting-intro { margin-bottom: 8px; max-width: none; }
13065 .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; }
13066 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
13067 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
13068 .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; }
13069 .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; }
13070 .section-spacer-top { margin-top: 28px; }
13071 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
13072 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
13073 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
13074 .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); }
13075 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
13076 .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; }
13077 .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; }
13078 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
13079 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
13080 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
13081 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
13082 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
13083 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
13084 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
13085 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
13086 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
13087 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
13088 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
13089 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
13090 .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); }
13091 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
13092 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
13093 .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; }
13094 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
13095 .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; }
13096 .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; }
13097 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
13098 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
13099 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
13100 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
13101 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
13102 .advanced-rule-description strong { color: var(--text); }
13103 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
13104 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
13105 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
13106 .review-link:hover { text-decoration: underline; }
13107 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
13108 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
13109 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
13110 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
13111 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
13112 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
13113 .review-card ul { padding-left: 18px; margin: 0; }
13114 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
13115 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
13116 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
13117 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
13118 .review-card { min-height: 0; }
13119 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
13120 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
13121 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
13122 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
13123 .lang-overflow-chip { position:relative; cursor:default; }
13124 .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; }
13125 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
13126 .git-inline-row { align-items:start; }
13127 .mixed-line-card { display:flex; flex-direction:column; }
13128 .preset-inline-row .toggle-card { justify-content: center; }
13129 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
13130 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
13131 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
13132 .explorer-title { font-size: 18px; font-weight: 850; }
13133 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
13134 .explorer-subtitle.wide { max-width: none; }
13135 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
13136 .better-spacing { align-items:flex-start; justify-content:flex-end; }
13137 .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; }
13138 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
13139 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
13140 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
13141 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
13142 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
13143 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
13144 .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; }
13145 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
13146 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
13147 .scope-stat-button.supported { background: var(--success-bg); }
13148 .scope-stat-button.skipped { background: var(--warn-bg); }
13149 .scope-stat-button.unsupported { background: var(--danger-bg); }
13150 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
13151 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
13152 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
13153 [data-tooltip] { position: relative; }
13154 [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); }
13155 [data-tooltip]:hover::after { display: block; }
13156 .scope-stat-button[data-tooltip] { cursor: pointer; }
13157 .badge[data-tooltip] { cursor: help; }
13158 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
13159 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
13160 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
13161 .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; }
13162 .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; }
13163 code { display:inline-block; margin-top:0; padding:2px 7px; }
13164 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
13165 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
13166 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
13167 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
13168 .language-pill.muted-pill { color: var(--muted); }
13169 button.language-pill { appearance:none; cursor:pointer; }
13170 .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); }
13171 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
13172 .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; }
13173 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
13174 .file-explorer-search-row { margin-left: auto; }
13175 .explorer-filter-select { min-width: 170px; width: 170px; }
13176 .explorer-search { min-width: 300px; width: 300px; }
13177 .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); }
13178 .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; }
13179 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
13180 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
13181 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
13182 .file-explorer-tree { max-height: 640px; overflow:auto; }
13183 .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); }
13184 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
13185 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
13186 .tree-row.hidden-by-filter { display:none !important; }
13187 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
13188 .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; }
13189 .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; }
13190 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
13191 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
13192 .tree-node { display:inline-flex; align-items:center; min-width:0; }
13193 .tree-node-dir { color: var(--text); font-weight: 800; }
13194 .tree-node-supported { color: var(--success-text); }
13195 .tree-node-skipped { color: var(--warn-text); }
13196 .tree-node-unsupported { color: var(--danger-text); }
13197 .tree-node-more { color: var(--muted-2); font-style: italic; }
13198 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
13199 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
13200 .tree-status-cell { display:flex; justify-content:flex-start; }
13201 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
13202 .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; }
13203 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
13204 .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; }
13205 @keyframes prevSpin { to { transform:rotate(360deg); } }
13206 .preview-loading-text { flex:1; min-width:0; }
13207 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
13208 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
13209 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
13210 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
13211 .cov-scan-idle { display:none; }
13212 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
13213 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
13214 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
13215 .cov-scan-title { font-weight:600; font-size:12.5px; }
13216 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
13217 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
13218 .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; }
13219 .cov-scan-use:hover { opacity:.75; }
13220 .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; }
13221 .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; }
13222 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
13223 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
13224 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
13225 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
13226 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
13227 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
13228 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
13229 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
13230 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
13231 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
13232 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
13233 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
13234 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
13235 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
13236 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
13237 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
13238 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
13239 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
13240 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
13241 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
13242 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
13243 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
13244 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
13245 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
13246 .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); }
13247 .loading.active { display:flex; }
13248 .loading-card { width: min(840px, calc(100vw - 40px)); border-radius: 20px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 24px 56px rgba(0,0,0,0.26); padding: 42px 48px; }
13249 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
13250 .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent-2), var(--oxide,#d37a4c)); animation: pulseBar 1.6s ease-in-out infinite; }
13251 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
13252 .lc-badge { display:inline-flex;align-items:center;gap:10px;background:linear-gradient(135deg,rgba(211,122,76,0.16),rgba(184,93,51,0.08));border:1.5px solid rgba(211,122,76,0.44);border-radius:10px;padding:8px 18px 8px 13px;font-size:12px;font-weight:800;color:var(--oxide,#d37a4c);text-transform:uppercase;letter-spacing:.07em;margin-bottom:20px;box-shadow:0 2px 16px rgba(211,122,76,0.16); }
13253 .lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
13254 .lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
13255 .lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
13256 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
13257 @keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
13258 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
13259 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
13260 .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; }
13261 .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
13262 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
13263 .lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
13264 .lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
13265 .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; }
13266 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
13267 .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; }
13268 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
13269 .lc-step.done { color:var(--muted);opacity:0.55; }
13270 .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; }
13271 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
13272 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
13273 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
13274 .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; }
13275 .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; }
13276 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
13277 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
13278 .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; }
13279 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
13280 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
13281 .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; }
13282 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
13283 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
13284 .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; }
13285 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
13286 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
13287 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
13288 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
13289 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
13290 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
13291 .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; }
13292 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
13293 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
13294 .hidden { display:none !important; }
13295 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13296 .site-footer a{color:var(--muted);}
13297 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
13298 @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; } }
13299 .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;}
13300 @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));}}
13301 .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;}
13302 .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; }
13303 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
13304 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
13305 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
13306 .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; }
13307 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
13308 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
13309 .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:5px 10px; border-radius:7px; font-size:11px; font-weight:600; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .18s ease; z-index:300; }
13310 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
13311 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
13312 .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; }
13313 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
13314 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
13315 .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; }
13316 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
13317 .info-icon-btn:hover { color:var(--text); }
13318 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); }
13319 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
13320 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
13321 .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;}
13322 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
13323 .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;}
13324 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
13325 #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);}
13326 #offline-file-banner.show{display:flex;}
13327 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
13328 #offline-file-banner .ofb-text{flex:1;}
13329 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
13330 #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;}
13331 #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;}
13332 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
13333 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
13334 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
13335 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
13336 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
13337 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
13338 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
13339 </style>
13340</head>
13341<body id="page-top">
13342 <div id="offline-file-banner" role="alert">
13343 <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>
13344 <span class="ofb-text">
13345 Charts, images, and navigation require the oxide-sloc server.
13346 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
13347 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
13348 The metric tables below are fully readable without the server.
13349 </span>
13350 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
13351 </div>
13352 <script>(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>
13353 <div class="background-watermarks" aria-hidden="true">
13354 <img src="/images/logo/logo-text.png" alt="" />
13355 <img src="/images/logo/logo-text.png" alt="" />
13356 <img src="/images/logo/logo-text.png" alt="" />
13357 <img src="/images/logo/logo-text.png" alt="" />
13358 <img src="/images/logo/logo-text.png" alt="" />
13359 <img src="/images/logo/logo-text.png" alt="" />
13360 <img src="/images/logo/logo-text.png" alt="" />
13361 <img src="/images/logo/logo-text.png" alt="" />
13362 <img src="/images/logo/logo-text.png" alt="" />
13363 <img src="/images/logo/logo-text.png" alt="" />
13364 <img src="/images/logo/logo-text.png" alt="" />
13365 <img src="/images/logo/logo-text.png" alt="" />
13366 <img src="/images/logo/logo-text.png" alt="" />
13367 <img src="/images/logo/logo-text.png" alt="" />
13368 </div>
13369 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13370 <div class="top-nav">
13371 <div class="top-nav-inner">
13372 <a class="brand" href="/">
13373 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
13374 <div class="brand-copy">
13375 <div class="brand-title">OxideSLOC</div>
13376 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
13377 </div>
13378 </a>
13379 <div class="nav-project-slot">
13380 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
13381 <span class="nav-project-label">Project</span>
13382 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
13383 </div>
13384 </div>
13385 <div class="nav-status">
13386 <a class="nav-pill" href="/">Home</a>
13387 <div class="nav-dropdown">
13388 <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>
13389 <div class="nav-dropdown-menu">
13390 <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>
13391 </div>
13392 </div>
13393 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13394 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13395 <div class="nav-dropdown">
13396 <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>
13397 <div class="nav-dropdown-menu">
13398 <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>
13399 </div>
13400 </div>
13401 <div class="server-status-wrap" id="server-status-wrap">
13402 <div class="nav-pill server-online-pill" id="server-status-pill">
13403 <span class="status-dot" id="status-dot"></span>
13404 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
13405 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13406 </div>
13407 <div class="server-status-tip">
13408 {% if server_mode %}
13409 OxideSLOC is running in server mode — accessible on your LAN.
13410 {% else %}
13411 OxideSLOC is running locally — only accessible from this machine.
13412 {% endif %}
13413 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13414 </div>
13415 </div>
13416 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13417 <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>
13418 </button>
13419 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13420 <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>
13421 <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>
13422 </button>
13423 </div>
13424 </div>
13425 </div>
13426
13427 <div class="loading" id="loading">
13428 <div class="loading-card">
13429 <div class="lc-badge" id="lc-badge"><span class="lc-dot-wrap"><span class="lc-dot"></span><span class="lc-dot-ring"></span></span>Analysis running</div>
13430 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
13431 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
13432 <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>
13433 <div class="lc-steps" id="lc-steps">
13434 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
13435 <div class="lc-step-arrow">›</div>
13436 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
13437 <div class="lc-step-arrow">›</div>
13438 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
13439 <div class="lc-step-arrow">›</div>
13440 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
13441 </div>
13442 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
13443 <div class="lc-metrics" id="lc-metrics">
13444 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
13445 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
13446 <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>
13447 <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>
13448 </div>
13449 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
13450 <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>
13451 <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>
13452 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
13453 <div class="lc-actions hidden" id="lc-actions">
13454 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
13455 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
13456 </div>
13457 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
13458 <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>
13459 Cancel scan
13460 </button>
13461 </div>
13462 </div>
13463
13464 <div class="page">
13465 <div class="workbench-strip">
13466 <div class="workbench-box wb-stats">
13467 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
13468 <span class="wb-stats-title">Analysis session</span>
13469 </div>
13470 <div class="ws-left">
13471 <div class="ws-stat ws-stat-analyzers">
13472 <span class="ws-label">Analyzers</span>
13473 <span class="ws-value">
13474 <span class="ws-badge">41 languages</span>
13475 </span>
13476 <div class="ws-lang-tooltip">
13477 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
13478 <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>
13479 <div class="ws-lang-grid">
13480 <span class="ws-lang-item">Assembly</span>
13481 <span class="ws-lang-item">C</span>
13482 <span class="ws-lang-item">C++</span>
13483 <span class="ws-lang-item">C#</span>
13484 <span class="ws-lang-item">Clojure</span>
13485 <span class="ws-lang-item">CSS</span>
13486 <span class="ws-lang-item">Dart</span>
13487 <span class="ws-lang-item">Dockerfile</span>
13488 <span class="ws-lang-item">Elixir</span>
13489 <span class="ws-lang-item">Erlang</span>
13490 <span class="ws-lang-item">F#</span>
13491 <span class="ws-lang-item">Go</span>
13492 <span class="ws-lang-item">Groovy</span>
13493 <span class="ws-lang-item">Haskell</span>
13494 <span class="ws-lang-item">HTML</span>
13495 <span class="ws-lang-item">Java</span>
13496 <span class="ws-lang-item">JavaScript</span>
13497 <span class="ws-lang-item">Julia</span>
13498 <span class="ws-lang-item">Kotlin</span>
13499 <span class="ws-lang-item">Lua</span>
13500 <span class="ws-lang-item">Makefile</span>
13501 <span class="ws-lang-item">Nim</span>
13502 <span class="ws-lang-item">Obj-C</span>
13503 <span class="ws-lang-item">OCaml</span>
13504 <span class="ws-lang-item">Perl</span>
13505 <span class="ws-lang-item">PHP</span>
13506 <span class="ws-lang-item">PowerShell</span>
13507 <span class="ws-lang-item">Python</span>
13508 <span class="ws-lang-item">R</span>
13509 <span class="ws-lang-item">Ruby</span>
13510 <span class="ws-lang-item">Rust</span>
13511 <span class="ws-lang-item">Scala</span>
13512 <span class="ws-lang-item">SCSS</span>
13513 <span class="ws-lang-item">Shell</span>
13514 <span class="ws-lang-item">SQL</span>
13515 <span class="ws-lang-item">Svelte</span>
13516 <span class="ws-lang-item">Swift</span>
13517 <span class="ws-lang-item">TypeScript</span>
13518 <span class="ws-lang-item">Vue</span>
13519 <span class="ws-lang-item">XML</span>
13520 <span class="ws-lang-item">Zig</span>
13521 </div>
13522 </div>
13523 </div>
13524 <div class="ws-divider"></div>
13525 <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>
13526 <div class="ws-divider"></div>
13527 <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts — JSON, HTML, and PDF reports — are written after each completed scan.">
13528 <span class="ws-label">Output</span>
13529 <span class="ws-value">
13530 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
13531 <span id="ws-output-root">project/sloc</span>
13532 </button>
13533 </span>
13534 </div>
13535 </div>
13536 </div>
13537 <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.">
13538 <div class="ws-history-label">Scan history</div>
13539 <div class="ws-history-inner">
13540 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
13541 <div class="ws-mini-label">Scans</div>
13542 <div class="ws-mini-value" id="ws-scan-count">—</div>
13543 </div>
13544 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
13545 <div class="ws-mini-label">Last Scan</div>
13546 <div class="ws-mini-value" id="ws-last-scan">—</div>
13547 </div>
13548 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
13549 <div class="ws-mini-label">Branch</div>
13550 <div class="ws-mini-value" id="ws-branch">—</div>
13551 </div>
13552 </div>
13553 </div>
13554 </div>
13555
13556 <div class="layout">
13557 <aside class="side-stack">
13558 <section class="step-nav">
13559 <h3>Guided scan setup</h3>
13560 <div class="sidebar-scroll-divider"></div>
13561 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
13562 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
13563 Top of page
13564 </a>
13565 <div class="sidebar-scroll-divider"></div>
13566 <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
13567 <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>
13568 <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>
13569 <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>
13570
13571 <div class="step-steps-divider"></div>
13572
13573 <div class="step-nav-info" id="step-nav-info">
13574 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
13575 <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>
13576 </div>
13577
13578 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
13579 <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>
13580 <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>
13581 <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>
13582 </div>
13583
13584 <div class="quick-scan-divider"></div>
13585 <div class="quick-scan-section">
13586 <div class="quick-scan-label">No customization needed?</div>
13587 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
13588 <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>
13589 Quick Scan
13590 </button>
13591 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
13592 </div>
13593
13594 <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>
13595 <div class="sidebar-scroll-divider"></div>
13596 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
13597 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
13598 Skip to bottom
13599 </a>
13600 </section>
13601
13602 </aside>
13603
13604 <section class="card">
13605 <div class="card-header">
13606 <div class="card-title-row">
13607 <div>
13608 <h1 class="card-title">Guided scan configuration</h1>
13609 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
13610 </div>
13611 <div class="wizard-progress" aria-label="Scan setup progress">
13612 <div class="wizard-progress-top">
13613 <span class="wizard-progress-label">Setup progress</span>
13614 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
13615 </div>
13616 <div class="wizard-progress-track">
13617 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
13618 </div>
13619 </div>
13620 </div>
13621 </div>
13622 <div class="card-body">
13623 <form method="post" action="/analyze" id="analyze-form">
13624 <div class="wizard-step active" data-step="1">
13625 <div class="section">
13626 <div class="section-kicker">Step 1</div>
13627 <h2>Select project and preview scope</h2>
13628 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
13629 <div class="field">
13630 <label for="path">Project path</label>
13631 {% if !git_repo.is_empty() %}
13632 <div class="git-source-banner">
13633 <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>
13634 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
13635 <a href="/git-browser">← Back to Git Browser</a>
13636 </div>
13637 {% endif %}
13638 <div class="path-scope-grid">
13639 {% if !git_repo.is_empty() %}
13640 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
13641 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
13642 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
13643 {% else %}
13644 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
13645 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
13646 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
13647 {% endif %}
13648 <div class="path-scope-sep"></div>
13649 <div class="scope-legend-row">
13650 <span class="scope-legend-label">Scope legend:</span>
13651 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
13652 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
13653 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
13654 </div>
13655 </div>
13656 {% if git_repo.is_empty() %}
13657 {% if server_mode %}
13658 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
13659 ℹ️ Files are compressed and streamed — no fixed size limit.
13660 </div>
13661 {% endif %}
13662 <div class="path-info-row">
13663 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
13664 <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>
13665 <span id="project-size-text">Project size: —</span>
13666 </button>
13667 </div>
13668 {% else %}
13669 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
13670 {% endif %}
13671 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
13672 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
13673 </div>
13674
13675 <div class="scope-preview-divider" aria-hidden="true"></div>
13676
13677 <div id="preview-panel">
13678 <div class="preview-error">Loading preview...</div>
13679 </div>
13680 </div>
13681
13682 <div class="section" style="margin-top:14px;">
13683 <div class="preset-inline-row git-inline-row">
13684 <div class="toggle-card" style="margin:0;">
13685 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
13686 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
13687 <label class="checkbox">
13688 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
13689 <div>
13690 <span>Detect and separate git submodules</span>
13691 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
13692 </div>
13693 </label>
13694 </div>
13695 <div class="explainer-card prominent" style="margin:0;">
13696 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13697 <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>
13698 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
13699 path = libs/core
13700 url = https://github.com/org/core.git
13701
13702[submodule "libs/ui"]
13703 path = libs/ui
13704 url = https://github.com/org/ui.git</div>
13705 </div>
13706 </div>
13707 </div>
13708
13709 <div class="section">
13710 <div class="field-grid">
13711 <div class="field">
13712 <label for="include_globs">Include globs</label>
13713 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
13714 <div class="hint">Use line-separated or comma-separated patterns when you want to narrow the scan to only certain folders or file types. If you leave this empty, everything under the project path is eligible first, and then exclude rules trim it down.</div>
13715 </div>
13716 <div class="field">
13717 <label for="exclude_globs">Exclude globs</label>
13718 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
13719 <div id="quick-exclude-chips" class="quick-excl-row">
13720 <span class="quick-excl-label">Quick add:</span>
13721 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
13722 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
13723 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
13724 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
13725 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
13726 <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>
13727 </div>
13728 <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>
13729 </div>
13730 </div>
13731 <div class="glob-guidance-grid">
13732 <div class="glob-guidance-card">
13733 <strong>How to read them</strong>
13734 <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>
13735 </div>
13736 <div class="glob-guidance-card">
13737 <strong>Common include examples</strong>
13738 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
13739 </div>
13740 <div class="glob-guidance-card">
13741 <strong>Common exclude examples</strong>
13742 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
13743 </div>
13744 </div>
13745 </div>
13746
13747 <div class="section" style="margin-top:14px;">
13748 <div class="preset-inline-row git-inline-row">
13749 <div class="toggle-card" style="margin:0;">
13750 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
13751 <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>
13752 <div class="field" style="margin:0;">
13753 <div class="input-group compact">
13754 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
13755 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
13756 </div>
13757 <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>
13758 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
13759 </div>
13760 </div>
13761 <div class="explainer-card prominent" style="margin:0;">
13762 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13763 <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>
13764 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
13765lcov --capture --directory . --output-file coverage/lcov.info
13766
13767# C / C++ — llvm-cov (LCOV)
13768llvm-profdata merge -sparse default.profraw -o default.profdata
13769llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
13770
13771# C# — coverlet (Cobertura XML)
13772dotnet test --collect:"XPlat Code Coverage"
13773
13774# Python — pytest-cov (Cobertura XML)
13775pytest --cov --cov-report=xml
13776
13777# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
13778./gradlew jacocoTestReport</div>
13779 </div>
13780 </div>
13781 </div>
13782
13783 <div class="wizard-actions">
13784 <div class="left"></div>
13785 <div class="right">
13786 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
13787 </div>
13788 </div>
13789 </div>
13790
13791 <div class="wizard-step" data-step="2">
13792 <div class="section">
13793 <div class="section-kicker">Step 2</div>
13794 <h2>Choose counting behavior</h2>
13795 <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>
13796<div class="subsection-bar">Primary line classification</div>
13797 <div class="preset-kv-row">
13798 <div class="toggle-card mixed-line-card" style="margin:0;">
13799 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
13800 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
13801 <select id="mixed_line_policy" name="mixed_line_policy">
13802 <option value="code_only">Code only</option>
13803 <option value="code_and_comment">Code and comment</option>
13804 <option value="comment_only">Comment only</option>
13805 <option value="separate_mixed_category">Separate mixed category</option>
13806 </select>
13807 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
13808 </div>
13809 <div class="explainer-card prominent" style="margin:0;">
13810 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
13811 <div class="explainer-body" id="mixed-policy-description"></div>
13812 <div class="code-sample" id="mixed-policy-example"></div>
13813 </div>
13814 </div>
13815 </div>
13816
13817 <div class="subsection-bar">Additional scan rules</div>
13818 <div class="scan-rules-grid">
13819 <div class="preset-inline-row">
13820 <div class="toggle-card" style="margin:0;">
13821 <div class="field-help-title">Generated files</div>
13822 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
13823 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13824 </div>
13825 <div class="explainer-card prominent" style="margin:0;">
13826 <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>
13827 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
13828# Files matching codegen patterns are excluded:
13829# *.generated.cs *.pb.go *.g.dart</div>
13830 </div>
13831 </div>
13832 <div class="preset-inline-row">
13833 <div class="toggle-card" style="margin:0;">
13834 <div class="field-help-title">Minified files</div>
13835 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
13836 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13837 </div>
13838 <div class="explainer-card prominent" style="margin:0;">
13839 <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>
13840 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
13841# Heuristic: very long lines + low whitespace ratio
13842# jquery.min.js bundle.min.css → skipped</div>
13843 </div>
13844 </div>
13845 <div class="preset-inline-row">
13846 <div class="toggle-card" style="margin:0;">
13847 <div class="field-help-title">Vendor directories</div>
13848 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
13849 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13850 </div>
13851 <div class="explainer-card prominent" style="margin:0;">
13852 <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>
13853 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
13854# Directories named vendor/ node_modules/ third_party/
13855# → entire subtree is excluded from totals</div>
13856 </div>
13857 </div>
13858 <div class="preset-inline-row">
13859 <div class="toggle-card" style="margin:0;">
13860 <div class="field-help-title">Lockfiles and manifests</div>
13861 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
13862 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
13863 </div>
13864 <div class="explainer-card prominent" style="margin:0;">
13865 <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>
13866 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
13867# Files like package-lock.json Cargo.lock yarn.lock
13868# → skipped unless this is enabled</div>
13869 </div>
13870 </div>
13871 <div class="preset-inline-row">
13872 <div class="toggle-card" style="margin:0;">
13873 <div class="field-help-title">Binary handling</div>
13874 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
13875 <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>
13876 </div>
13877 <div class="explainer-card prominent" style="margin:0;">
13878 <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>
13879 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
13880# Detected via long lines + low whitespace heuristic
13881# .png .exe .so → skipped silently</div>
13882 </div>
13883 </div>
13884 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
13885 <div class="toggle-card" style="margin:0;">
13886 <div class="field-help-title">Python docstrings</div>
13887 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
13888 <label class="checkbox">
13889 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
13890 <span>Count as comment-style lines</span>
13891 </label>
13892 </div>
13893 <div class="explainer-card prominent" style="margin:0;">
13894 <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>
13895 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
13896 </div>
13897 </div>
13898 </div>
13899 <div class="subsection-bar">IEEE 1045-1992 counting</div>
13900 <div class="scan-rules-grid">
13901 <div class="preset-inline-row">
13902 <div class="toggle-card" style="margin:0;">
13903 <div class="field-help-title">Continuation lines</div>
13904 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
13905 <select name="continuation_line_policy" id="continuation_line_policy">
13906 <option value="each_physical_line" selected>Each physical line (default)</option>
13907 <option value="collapse_to_logical">Collapse to logical line</option>
13908 </select>
13909 </div>
13910 <div class="explainer-card prominent" style="margin:0;">
13911 <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>
13912 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
13913 ((a) > (b) ? (a) : (b))
13914# each_physical_line → 2 SLOC
13915# collapse_to_logical → 1 SLOC</div>
13916 </div>
13917 </div>
13918 <div class="preset-inline-row">
13919 <div class="toggle-card" style="margin:0;">
13920 <div class="field-help-title">Block-comment blanks</div>
13921 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
13922 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
13923 <option value="count_as_comment" selected>Count as comment (default)</option>
13924 <option value="count_as_blank">Count as blank</option>
13925 </select>
13926 </div>
13927 <div class="explainer-card prominent" style="margin:0;">
13928 <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>
13929 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
13930 * Summary line
13931 * ← blank inside block comment
13932 * Detail line
13933 */
13934# count_as_comment → blank counts toward comments
13935# count_as_blank → blank counts toward blanks</div>
13936 </div>
13937 </div>
13938 <div class="preset-inline-row">
13939 <div class="toggle-card" style="margin:0;">
13940 <div class="field-help-title">Compiler directives</div>
13941 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
13942 <select name="count_compiler_directives" id="count_compiler_directives">
13943 <option value="enabled" selected>Include in code SLOC (default)</option>
13944 <option value="disabled">Exclude from code SLOC</option>
13945 </select>
13946 </div>
13947 <div class="explainer-card prominent" style="margin:0;">
13948 <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>
13949 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
13950#define BUF 256 ← compiler directive
13951int main() { … } ← code
13952# enabled → 3 code SLOC
13953# disabled → 1 code SLOC + 2 directive lines</div>
13954 </div>
13955 </div>
13956 </div>
13957
13958 <div class="subsection-bar">Code Style Analysis</div>
13959 <div class="scan-rules-grid">
13960 <div class="preset-inline-row">
13961 <div class="toggle-card" style="margin:0;">
13962 <div class="field-help-title">Style analysis</div>
13963 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
13964 <select name="style_analysis_enabled" id="style_analysis_enabled">
13965 <option value="enabled" selected>Enabled (default)</option>
13966 <option value="disabled">Disabled — skip style scoring</option>
13967 </select>
13968 </div>
13969 <div class="explainer-card prominent" style="margin:0;">
13970 <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> — every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> — style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
13971 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
13972# style_analysis_enabled = false (skip, faster scan)
13973# Disabling removes the Code Style section from the report.</div>
13974 </div>
13975 </div>
13976 <div class="preset-inline-row">
13977 <div class="toggle-card" style="margin:0;">
13978 <div class="field-help-title">Column-width threshold</div>
13979 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
13980 <select name="style_col_threshold" id="style_col_threshold">
13981 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
13982 <option value="100">100 columns (Uber Go, Google Java)</option>
13983 <option value="120">120 columns (Uber Go max, Kotlin)</option>
13984 </select>
13985 </div>
13986 <div class="explainer-card prominent" style="margin:0;">
13987 <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>
13988 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
13989# style_col_threshold = 100 (Uber Go, Google Java)
13990# style_col_threshold = 120 (Uber Go max, Kotlin)
13991# Files where <= 5% of lines exceed the limit
13992# are counted as "N-col compliant" in the report.</div>
13993 </div>
13994 </div>
13995 <div class="preset-inline-row">
13996 <div class="toggle-card" style="margin:0;">
13997 <div class="field-help-title">Score alert threshold</div>
13998 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
13999 <select name="style_score_threshold" id="style_score_threshold">
14000 <option value="0" selected>Off — no threshold (default)</option>
14001 <option value="40">40% — flag poorly styled files</option>
14002 <option value="50">50% — flag below-average files</option>
14003 <option value="60">60% — flag below-good files</option>
14004 <option value="70">70% — flag below-strong files</option>
14005 </select>
14006 </div>
14007 <div class="explainer-card prominent" style="margin:0;">
14008 <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>
14009 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
14010# style_score_threshold = 50 (flag files < 50%)
14011# Low-scoring files get a red left-border in the
14012# per-file style breakdown table.</div>
14013 </div>
14014 </div>
14015 </div>
14016
14017 <div class="always-tracked-tip">
14018 <div class="always-tracked-tip-icon">ℹ</div>
14019 <div class="always-tracked-tip-body">
14020 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
14021 <h4>Comment and blank-line basics & Lines on the boundary</h4>
14022 <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>
14023 </div>
14024 </div>
14025
14026 <div class="wizard-actions">
14027 <div class="left">
14028 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
14029 </div>
14030 <div class="right">
14031 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
14032 </div>
14033 </div>
14034 </div>
14035
14036 <div class="wizard-step" data-step="3">
14037 <div class="section">
14038 <div class="section-kicker">Step 3</div>
14039 <h2>Output and report identity</h2>
14040 <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>
14041 <div class="preset-kv-row">
14042 <div class="toggle-card" style="margin:0;">
14043 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
14044 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
14045 <select id="scan_preset">
14046 <option value="balanced">Balanced local scan</option>
14047 <option value="code_focused">Code focused</option>
14048 <option value="comment_audit">Comment audit</option>
14049 <option value="deep_review">Deep review</option>
14050 </select>
14051 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
14052 </div>
14053 <div class="explainer-card">
14054 <div class="field-help-title">Selected scan preset</div>
14055 <div class="explainer-body" id="scan-preset-description"></div>
14056 <div class="preset-summary-row" id="scan-preset-summary"></div>
14057 <div class="code-sample" id="scan-preset-example"></div>
14058 <div class="preset-note" id="scan-preset-note"></div>
14059 </div>
14060 </div>
14061 <hr class="step3-separator" />
14062 <div class="preset-kv-row">
14063 <div class="toggle-card" style="margin:0;">
14064 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
14065 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
14066 <select id="artifact_preset">
14067 <option value="review">Review bundle</option>
14068 <option value="full">Full bundle</option>
14069 <option value="html_only">HTML only</option>
14070 <option value="machine">Machine bundle</option>
14071 </select>
14072 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
14073 </div>
14074 <div class="explainer-card">
14075 <div class="field-help-title">Selected artifact preset</div>
14076 <div class="explainer-body" id="artifact-preset-description"></div>
14077 <div class="preset-summary-row" id="artifact-preset-summary"></div>
14078 <div class="code-sample" id="artifact-preset-example"></div>
14079 </div>
14080 </div>
14081 </div>
14082
14083 <div class="section section-spacer-top">
14084 <div class="output-field-row">
14085 <div class="field">
14086 <label for="output_dir">Output directory</label>
14087 {% if server_mode %}
14088 <div class="input-group compact">
14089 <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);" />
14090 </div>
14091 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
14092 {% else %}
14093 <div class="input-group compact">
14094 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
14095 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
14096 <button type="button" class="mini-button" id="use-default-output">Use default</button>
14097 </div>
14098 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
14099 {% endif %}
14100 </div>
14101 <div class="output-field-aside">
14102 <strong>Where reports land</strong>
14103 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.
14104 </div>
14105 </div>
14106 </div>
14107
14108 <div class="section section-spacer-top">
14109 <div class="output-field-row">
14110 <div class="field">
14111 <label for="report_title">Report title</label>
14112 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
14113 <div class="hint">Appears in HTML and PDF output headers.</div>
14114 </div>
14115 <div class="output-field-aside">
14116 <strong>Shown in exported artifacts</strong>
14117 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.
14118 </div>
14119 </div>
14120 </div>
14121
14122 <div class="section section-spacer-top">
14123 <div class="output-field-row">
14124 <div class="field">
14125 <label for="report_header_footer">Report header / footer</label>
14126 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
14127 <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>
14128 </div>
14129 <div class="output-field-aside">
14130 <strong>Page-level identification</strong>
14131 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.
14132 </div>
14133 </div>
14134 </div>
14135
14136 <div class="wizard-actions">
14137 <div class="left">
14138 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
14139 </div>
14140 <div class="right">
14141 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
14142 </div>
14143 </div>
14144 </div>
14145
14146 <div class="wizard-step" data-step="4">
14147 <div class="section">
14148 <div class="section-kicker">Step 4</div>
14149 <h2>Review selections and run</h2>
14150 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
14151 <div class="review-grid">
14152 <div class="review-card highlight">
14153 <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>
14154 <ul id="review-scan-summary"></ul>
14155 </div>
14156 <div class="review-card highlight">
14157 <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>
14158 <ul id="review-count-summary"></ul>
14159 </div>
14160 <div class="review-card">
14161 <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>
14162 <ul id="review-artifact-summary"></ul>
14163 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
14164 </div>
14165 <div class="review-card">
14166 <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>
14167 <ul id="review-preview-summary"></ul>
14168 </div>
14169 </div>
14170 </div>
14171
14172 <div class="wizard-actions">
14173 <div class="left">
14174 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
14175 </div>
14176 <div class="right">
14177 <button type="submit" id="submit-button" class="primary">Run analysis</button>
14178 </div>
14179 </div>
14180 </div>
14181 {% if server_mode %}
14182 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
14183 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
14184 {% endif %}
14185 </form>
14186 </div>
14187 </section>
14188 </div>
14189 </div>
14190
14191 <script nonce="{{ csp_nonce }}">
14192 (function () {
14193 function startScanPhase() {
14194 var phaseEl = document.getElementById("scan-phase");
14195 if (!phaseEl) return;
14196 var phases = [
14197 "Discovering files...",
14198 "Decoding file encodings...",
14199 "Detecting languages...",
14200 "Analyzing source lines...",
14201 "Applying counting policies...",
14202 "Aggregating results...",
14203 "Rendering report..."
14204 ];
14205 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
14206 var i = 0;
14207 function next() {
14208 phaseEl.style.opacity = "0";
14209 setTimeout(function () {
14210 phaseEl.textContent = phases[i];
14211 phaseEl.style.opacity = "0.85";
14212 var delay = durations[i] || 1800;
14213 i++;
14214 if (i < phases.length) { setTimeout(next, delay); }
14215 }, 200);
14216 }
14217 next();
14218 }
14219
14220 var form = document.getElementById("analyze-form");
14221 var loading = document.getElementById("loading");
14222 var submitButton = document.getElementById("submit-button");
14223 var pathInput = document.getElementById("path");
14224 var GIT_MODE = !!(pathInput && pathInput.readOnly);
14225 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
14226 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
14227 var outputDirInput = document.getElementById("output_dir");
14228 var reportTitleInput = document.getElementById("report_title");
14229 var previewPanel = document.getElementById("preview-panel");
14230 var refreshButton = document.getElementById("refresh-preview");
14231 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
14232 var useSamplePath = document.getElementById("use-sample-path");
14233 var useDefaultOutput = document.getElementById("use-default-output");
14234 var browsePath = document.getElementById("browse-path");
14235 var browseOutputDir = document.getElementById("browse-output-dir");
14236 var browseCoverage = document.getElementById("browse-coverage");
14237 var coverageInput = document.getElementById("coverage_file");
14238 var covScanStatus = document.getElementById("cov-scan-status");
14239 var coverageSuggestTimer = null;
14240 var covAutoFilled = false;
14241 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
14242 function fmtBytes(b) {
14243 b = Number(b) || 0;
14244 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
14245 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
14246 if (b >= 1024) return Math.round(b / 1024) + ' KB';
14247 return b + ' B';
14248 }
14249 var themeToggle = document.getElementById("theme-toggle");
14250
14251 function showBannerToast(msg, isError, opts) {
14252 opts = opts || {};
14253 var t = document.createElement('div');
14254 t.className = isError ? 'toast-error' : 'toast-success';
14255 var topPos = opts.top ? '80px' : null;
14256 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
14257 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
14258 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
14259 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
14260 if (opts.icon) {
14261 var inner = document.createElement('span');
14262 inner.innerHTML = opts.icon + ' ';
14263 t.appendChild(inner);
14264 }
14265 t.appendChild(document.createTextNode(msg));
14266 document.body.appendChild(t);
14267 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
14268 }
14269 var mixedLinePolicy = document.getElementById("mixed_line_policy");
14270 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
14271 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
14272 var scanPreset = document.getElementById("scan_preset");
14273 var artifactPreset = document.getElementById("artifact_preset");
14274 var includeGlobsInput = document.getElementById("include_globs");
14275 var excludeGlobsInput = document.getElementById("exclude_globs");
14276
14277 // Quick-exclude chips — append pattern to exclude_globs textarea.
14278 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
14279 chip.addEventListener("click", function() {
14280 var pattern = chip.getAttribute("data-pattern") || "";
14281 if (!pattern || !excludeGlobsInput) return;
14282 var current = excludeGlobsInput.value.trim();
14283 // For the "skip all" chip, replace any existing dep patterns cleanly.
14284 var patterns = pattern.split("\n");
14285 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
14286 var added = false;
14287 patterns.forEach(function(p) {
14288 p = p.trim();
14289 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
14290 });
14291 if (added) {
14292 excludeGlobsInput.value = lines.join("\n");
14293 excludeGlobsInput.dispatchEvent(new Event("input"));
14294 }
14295 chip.classList.add("active");
14296 });
14297 });
14298
14299 var liveReportTitle = document.getElementById("live-report-title");
14300 var navProjectPill = document.getElementById("nav-project-pill");
14301 var navProjectTitle = document.getElementById("nav-project-title");
14302 var reportTitlePreview = null;
14303 var wizardProgressFill = document.getElementById("wizard-progress-fill");
14304 var wizardProgressValue = document.getElementById("wizard-progress-value");
14305 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
14306 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
14307 var reportTitleTouched = false;
14308 var currentStep = 1;
14309 var previewTimer = null;
14310 var _previewGen = 0;
14311 var quickScanBtn = document.getElementById("quick-scan-btn");
14312
14313 function dismissAnalysisModal() {
14314 if (loading) loading.classList.remove("active");
14315 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14316 var el = document.getElementById(id);
14317 if (el) el.classList.add("hidden");
14318 });
14319 var cancelBtn = document.getElementById("lc-cancel-btn");
14320 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
14321 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
14322 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
14323 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
14324 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");}
14325 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
14326 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14327 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14328 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14329 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14330 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14331 }
14332
14333 var lcDismissBtn = document.getElementById("lc-dismiss");
14334 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
14335
14336 // When the browser restores this page from bfcache (Back button after navigating to results),
14337 // the loading overlay would still be showing its active state. Dismiss it immediately.
14338 window.addEventListener("pageshow", function(e) {
14339 if (e.persisted) { dismissAnalysisModal(); }
14340 });
14341
14342 function startAsyncAnalysis(formData) {
14343 var gitRepo = (formData.get("git_repo") || "").toString();
14344 var gitRef = (formData.get("git_ref") || "").toString();
14345 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
14346 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
14347
14348 var pathEl = document.getElementById("lc-path-text");
14349 if (pathEl) pathEl.textContent = displayPath;
14350
14351 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14352 var el = document.getElementById(id);
14353 if (el) el.classList.add("hidden");
14354 });
14355 var cancelBtn = document.getElementById("lc-cancel-btn");
14356 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
14357 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14358 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14359 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14360 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
14361 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
14362 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
14363 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");}
14364 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
14365
14366 if (loading) loading.classList.add("active");
14367
14368 var startTime = Date.now();
14369 var elapsedTimer = setInterval(function() {
14370 var s = Math.floor((Date.now() - startTime) / 1000);
14371 var el = document.getElementById("lc-elapsed");
14372 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
14373 }, 1000);
14374
14375 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
14376
14377 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();}
14378
14379 var PHASE_DESC = {
14380 'Starting': 'Initializing language analyzers and loading configuration…',
14381 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
14382 'Running': 'Running the lexical state machine across all discovered source files…',
14383 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
14384 'Done': 'Analysis complete — loading your results…',
14385 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
14386 };
14387 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
14388 function lcSetPhase(txt) {
14389 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
14390 var desc = document.getElementById("lc-stage-desc");
14391 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
14392 var step = PHASE_STEP[txt] || 1;
14393 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");}
14394 }
14395
14396 function lcShowCancelled() {
14397 clearInterval(elapsedTimer);
14398 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
14399 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
14400 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
14401 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
14402 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
14403 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
14404 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
14405 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
14406 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14407 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14408 }
14409
14410 var lcCancelBtn = document.getElementById("lc-cancel-btn");
14411 if (lcCancelBtn) {
14412 lcCancelBtn.onclick = function() {
14413 if (!activeWaitId) { dismissAnalysisModal(); return; }
14414 lcCancelBtn.disabled = true;
14415 lcCancelBtn.textContent = "Cancelling…";
14416 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
14417 .then(function() { lcShowCancelled(); })
14418 .catch(function() { lcShowCancelled(); });
14419 };
14420 }
14421
14422 function lcShowError(msg) {
14423 clearInterval(elapsedTimer);
14424 lcSetPhase("Failed");
14425 var msgEl = document.getElementById("lc-err-msg");
14426 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
14427 var errEl = document.getElementById("lc-err");
14428 var actEl = document.getElementById("lc-actions");
14429 if (errEl) errEl.classList.remove("hidden");
14430 if (actEl) actEl.classList.remove("hidden");
14431 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14432 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14433 }
14434
14435 function lcPoll(waitId) {
14436 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
14437 .then(function(r) {
14438 if (!r.ok) throw new Error("HTTP " + r.status);
14439 return r.json();
14440 })
14441 .then(function(data) {
14442 pollRetries = 0;
14443 if (data.state === "complete") {
14444 clearInterval(elapsedTimer);
14445 lcSetPhase("Done");
14446 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
14447 } else if (data.state === "failed") {
14448 lcShowError(data.message);
14449 } else if (data.state === "cancelled") {
14450 lcShowCancelled();
14451 } else {
14452 var s = Math.floor((Date.now() - startTime) / 1000);
14453 if (s > 90 && !warnShown) {
14454 warnShown = true;
14455 var w = document.getElementById("lc-warn");
14456 if (w) w.classList.remove("hidden");
14457 }
14458 lcSetPhase(data.phase || "Running");
14459 var fd = data.files_done || 0, ft = data.files_total || 0;
14460 if (ft > 0) {
14461 var card = document.getElementById("lc-files-card");
14462 if (card) card.classList.remove("hidden");
14463 var el = document.getElementById("lc-files");
14464 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
14465 var now = Date.now();
14466 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
14467 if (fdelta > 0 && tdelta > 0.4) {
14468 var fps = Math.round(fdelta / tdelta);
14469 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
14470 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
14471 }
14472 lastFd = fd; lastFdTime = now;
14473 }
14474 setTimeout(function() { lcPoll(waitId); }, 1500);
14475 }
14476 })
14477 .catch(function() {
14478 pollRetries++;
14479 if (pollRetries >= 5) {
14480 lcShowError("Lost connection to server. Reload to check status.");
14481 } else {
14482 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
14483 }
14484 });
14485 }
14486
14487 var params = new URLSearchParams(formData);
14488 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
14489 .then(function(r) {
14490 var waitId = r.headers.get("x-wait-id");
14491 if (!waitId) { window.location.href = "/scan"; return; }
14492 activeWaitId = waitId;
14493 setTimeout(function() { lcPoll(waitId); }, 1500);
14494 })
14495 .catch(function(err) {
14496 lcShowError("Could not reach server: " + (err.message || err));
14497 });
14498 }
14499
14500 if (quickScanBtn) {
14501 quickScanBtn.addEventListener("click", function () {
14502 var pathVal = pathInput ? pathInput.value.trim() : "";
14503 if (!pathVal) {
14504 alert("Please enter or browse to a project path first.");
14505 return;
14506 }
14507 quickScanBtn.disabled = true;
14508 quickScanBtn.textContent = "Scanning...";
14509 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
14510 startAsyncAnalysis(new FormData(form));
14511 });
14512 }
14513
14514 var mixedPolicyInfo = {
14515 code_only: {
14516 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.",
14517 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'
14518 },
14519 code_and_comment: {
14520 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.",
14521 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'
14522 },
14523 comment_only: {
14524 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.",
14525 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'
14526 },
14527 separate_mixed_category: {
14528 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.",
14529 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'
14530 }
14531 };
14532
14533 var scanPresetInfo = {
14534 balanced: {
14535 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.",
14536 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
14537 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
14538 note: "Best when you want a stable local overview before making deeper adjustments.",
14539 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14540 },
14541 code_focused: {
14542 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
14543 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
14544 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
14545 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
14546 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14547 },
14548 comment_audit: {
14549 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
14550 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
14551 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
14552 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
14553 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14554 },
14555 deep_review: {
14556 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
14557 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
14558 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
14559 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
14560 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
14561 }
14562 };
14563
14564 var artifactPresetInfo = {
14565 review: {
14566 description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
14567 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
14568 example: "Ideal for a quick local review before sharing results."
14569 },
14570 full: {
14571 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
14572 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
14573 example: "Use when producing a deliverable or storing a snapshot for future comparison."
14574 },
14575 html_only: {
14576 description: "Standalone HTML report only. No PDF generation, no data files.",
14577 chips: ["HTML only"],
14578 example: "Fastest option when you only need to open the report in a browser."
14579 },
14580 machine: {
14581 description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
14582 chips: ["JSON", "CSV", "no HTML", "no PDF"],
14583 example: "Use in CI to capture metrics without generating visual reports."
14584 }
14585 };
14586
14587 function applyArtifactPreset() {
14588 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
14589 if (!info) return;
14590 var descEl = document.getElementById("artifact-preset-description");
14591 var exampleEl = document.getElementById("artifact-preset-example");
14592 if (descEl) descEl.textContent = info.description;
14593 if (exampleEl) exampleEl.textContent = info.example;
14594 renderPresetChips("artifact-preset-summary", info.chips);
14595 }
14596
14597 function applyTheme(theme) {
14598 if (theme === "dark") document.body.classList.add("dark-theme");
14599 else document.body.classList.remove("dark-theme");
14600 }
14601
14602 function loadSavedTheme() {
14603 var saved = null;
14604 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
14605 applyTheme(saved === "dark" ? "dark" : "light");
14606 }
14607
14608 function updateScrollProgress() {
14609 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
14610 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
14611 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
14612 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
14613 var step = Math.min(Math.max(currentStep, 1), 4);
14614 var base = stepBase[step];
14615 var end = stepEnd[step];
14616
14617 var scrollFrac = 0;
14618 var activePanel = document.querySelector(".wizard-step.active");
14619 if (activePanel) {
14620 var scrollTop = window.scrollY || window.pageYOffset || 0;
14621 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
14622 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
14623 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
14624 var scrolled = scrollTop + viewH - panelTop;
14625 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
14626 }
14627
14628 var percent = Math.round(base + (end - base) * scrollFrac);
14629 percent = Math.min(end, Math.max(base, percent));
14630 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
14631 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
14632 }
14633
14634 function updateWizardProgress() {
14635 updateScrollProgress();
14636 }
14637
14638 var stepDescriptions = [
14639 "Choose a project folder, apply scope filters, and preview which files will be counted.",
14640 "Configure how mixed code-plus-comment lines and docstrings are classified.",
14641 "Pick your output formats, scan preset, and where reports are saved.",
14642 "Review all settings and launch the analysis."
14643 ];
14644
14645 function updateStepNav(step) {
14646 var infoLabel = document.getElementById("step-nav-info-label");
14647 var infoDesc = document.getElementById("step-nav-info-desc");
14648 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
14649 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
14650 }
14651
14652 function updateSidebarSummary() {
14653 var sumPath = document.getElementById("sum-path");
14654 var sumPreset = document.getElementById("sum-preset");
14655 var sumOutput = document.getElementById("sum-output");
14656 var sidebarSummary = document.getElementById("sidebar-summary");
14657 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
14658 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
14659 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
14660 if (sumPath) sumPath.textContent = pathVal || "—";
14661 if (sumPreset) sumPreset.textContent = presetVal || "—";
14662 if (sumOutput) sumOutput.textContent = outputVal || "—";
14663 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
14664 }
14665
14666 function setStep(step, pushHistory) {
14667 currentStep = step;
14668 stepPanels.forEach(function (panel) {
14669 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
14670 });
14671 stepButtons.forEach(function (button) {
14672 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
14673 });
14674 var layoutEl = document.querySelector(".layout");
14675 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
14676 updateWizardProgress();
14677 updateStepNav(step);
14678 stepButtons.forEach(function(btn) {
14679 var t = Number(btn.getAttribute("data-step-target"));
14680 btn.classList.toggle("done", t < step);
14681 });
14682 updateSidebarSummary();
14683
14684 if (pushHistory !== false) {
14685 try {
14686 history.pushState({ wizardStep: step }, "", "#step" + step);
14687 } catch (e) {}
14688 }
14689
14690 window.scrollTo({ top: 0, behavior: "instant" });
14691 }
14692
14693 window.addEventListener("popstate", function (e) {
14694 if (e.state && e.state.wizardStep) {
14695 setStep(e.state.wizardStep, false);
14696 } else {
14697 var hashMatch = location.hash.match(/^#step([1-4])$/);
14698 if (hashMatch) setStep(Number(hashMatch[1]), false);
14699 }
14700 });
14701
14702 function inferTitleFromPath(value) {
14703 if (!value) return "project";
14704 var cleaned = value.replace(/[\/\\]+$/, "");
14705 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
14706 return parts.length ? parts[parts.length - 1] : value;
14707 }
14708
14709 function updateReportTitleFromPath() {
14710 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
14711 if (!reportTitleTouched) {
14712 reportTitleInput.value = inferred;
14713 }
14714 var title = reportTitleInput.value || inferred;
14715 if (liveReportTitle) liveReportTitle.textContent = title;
14716 if (reportTitlePreview) reportTitlePreview.textContent = title;
14717 document.title = "OxideSLOC | " + title;
14718
14719 var projectPath = (pathInput.value || "").trim();
14720 if (navProjectPill && navProjectTitle) {
14721 if (projectPath.length > 0) {
14722 navProjectTitle.textContent = inferred;
14723 navProjectPill.classList.add("visible");
14724 } else {
14725 navProjectTitle.textContent = "";
14726 navProjectPill.classList.remove("visible");
14727 }
14728 }
14729 }
14730
14731 function updateMixedPolicyUI() {
14732 var key = mixedLinePolicy.value || "code_only";
14733 var info = mixedPolicyInfo[key];
14734 document.getElementById("mixed-policy-description").textContent = info.description;
14735 document.getElementById("mixed-policy-example").textContent = info.example;
14736 }
14737
14738 function updatePythonDocstringUI() {
14739 var checked = !!pythonDocstrings.checked;
14740 document.getElementById("python-docstring-example").textContent = checked
14741 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
14742 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
14743 document.getElementById("python-docstring-live-help").textContent = checked
14744 ? "Enabled: docstrings contribute to comment-style totals."
14745 : "Disabled: docstrings are not counted as comment content.";
14746 }
14747
14748 function renderPresetChips(targetId, chips) {
14749 var target = document.getElementById(targetId);
14750 if (!target) return;
14751 target.innerHTML = (chips || []).map(function (chip) {
14752 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
14753 }).join('');
14754 }
14755
14756 function updatePresetDescriptions() {
14757 var scanInfo = scanPresetInfo[scanPreset.value];
14758 if (!scanInfo) return;
14759 document.getElementById("scan-preset-description").textContent = scanInfo.description;
14760 document.getElementById("scan-preset-example").textContent = scanInfo.example;
14761 document.getElementById("scan-preset-note").textContent = scanInfo.note;
14762 renderPresetChips("scan-preset-summary", scanInfo.chips);
14763 }
14764
14765 function applyScanPreset() {
14766 var info = scanPresetInfo[scanPreset.value];
14767 if (!info || !info.apply) return;
14768 mixedLinePolicy.value = info.apply.mixed;
14769 pythonDocstrings.checked = !!info.apply.docstrings;
14770 document.getElementById("generated_file_detection").value = info.apply.generated;
14771 document.getElementById("minified_file_detection").value = info.apply.minified;
14772 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
14773 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
14774 document.getElementById("binary_file_behavior").value = info.apply.binary;
14775 updateMixedPolicyUI();
14776 updatePythonDocstringUI();
14777 }
14778
14779 function updateReview() {
14780 var scanSummary = document.getElementById("review-scan-summary");
14781 var countSummary = document.getElementById("review-count-summary");
14782 var artifactSummary = document.getElementById("review-artifact-summary");
14783 var outputSummary = document.getElementById("review-output-summary");
14784 var previewSummary = document.getElementById("review-preview-summary");
14785 var readinessSummary = document.getElementById("review-readiness-summary");
14786 var includeText = document.getElementById("include_globs").value.trim();
14787 var excludeText = document.getElementById("exclude_globs").value.trim();
14788 var sidePathPreview = document.getElementById("side-path-preview");
14789 var sideOutputPreview = document.getElementById("side-output-preview");
14790 var sideTitlePreview = document.getElementById("side-title-preview");
14791
14792 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
14793 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
14794 if (sideTitlePreview) {
14795 var rt = document.getElementById("report_title");
14796 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
14797 }
14798
14799 scanSummary.innerHTML = ""
14800 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
14801 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
14802 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
14803
14804 countSummary.innerHTML = ""
14805 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
14806 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
14807 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
14808 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
14809 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
14810 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
14811 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
14812 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
14813
14814 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
14815
14816 outputSummary.innerHTML = ""
14817 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
14818 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
14819
14820 if (previewSummary) {
14821 if (GIT_MODE) {
14822 previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode — the repository will be cloned and fully analyzed during the scan run.</li>';
14823 } else {
14824 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
14825 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
14826 var statMap = {};
14827 statButtons.forEach(function (button) {
14828 var valueNode = button.querySelector('.scope-stat-value');
14829 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
14830 });
14831 previewSummary.innerHTML = ''
14832 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
14833 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
14834 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
14835 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
14836 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
14837 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
14838
14839 if (readinessSummary) {
14840 readinessSummary.innerHTML = ''
14841 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
14842 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
14843 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
14844 }
14845 } // end else (non-GIT_MODE)
14846 }
14847 }
14848
14849 function escapeHtml(value) {
14850 return String(value)
14851 .replace(/&/g, "&")
14852 .replace(/</g, "<")
14853 .replace(/>/g, ">")
14854 .replace(/"/g, """)
14855 .replace(/'/g, "'");
14856 }
14857
14858 function isPythonVisible() {
14859 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
14860 }
14861
14862 function syncPythonVisibility() {
14863 var html = previewPanel.textContent || "";
14864 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
14865 pythonWraps.forEach(function (node) {
14866 node.classList.toggle("hidden", !hasPython);
14867 });
14868 }
14869
14870 function attachPreviewInteractions() {
14871 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
14872 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
14873 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
14874 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
14875 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
14876 var searchInput = previewPanel.querySelector("#explorer-search");
14877 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
14878 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
14879 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
14880 var activeFilter = "all";
14881 var activeLanguage = "";
14882 var searchTerm = "";
14883 var currentSortKey = null;
14884 var currentSortOrder = "asc";
14885 var childRows = {};
14886
14887 rows.forEach(function (row) {
14888 var parentId = row.getAttribute("data-parent-id") || "";
14889 var rowId = row.getAttribute("data-row-id") || "";
14890 if (!childRows[parentId]) childRows[parentId] = [];
14891 childRows[parentId].push(rowId);
14892 });
14893
14894 function rowById(id) {
14895 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
14896 }
14897
14898 function hasCollapsedAncestor(row) {
14899 var parentId = row.getAttribute("data-parent-id");
14900 while (parentId) {
14901 var parent = rowById(parentId);
14902 if (!parent) break;
14903 if (parent.getAttribute("data-expanded") === "false") return true;
14904 parentId = parent.getAttribute("data-parent-id");
14905 }
14906 return false;
14907 }
14908
14909 function updateToggleGlyph(row) {
14910 var toggle = row.querySelector(".tree-toggle");
14911 if (!toggle) return;
14912 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
14913 }
14914
14915 function rowSortValue(row, key) {
14916 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
14917 }
14918
14919 function updateSortButtons() {
14920 sortButtons.forEach(function (button) {
14921 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
14922 var indicator = button.querySelector(".tree-sort-indicator");
14923 button.classList.toggle("active", isActive);
14924 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
14925 if (indicator) {
14926 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
14927 }
14928 });
14929 }
14930
14931 function sortSiblingRows() {
14932 if (!treeContainer) {
14933 updateSortButtons();
14934 return;
14935 }
14936
14937 var rowMap = {};
14938 var childrenMap = {};
14939 rows.forEach(function (row) {
14940 var rowId = row.getAttribute("data-row-id");
14941 var parentId = row.getAttribute("data-parent-id") || "";
14942 rowMap[rowId] = row;
14943 if (!childrenMap[parentId]) childrenMap[parentId] = [];
14944 childrenMap[parentId].push(rowId);
14945 });
14946
14947 Object.keys(childrenMap).forEach(function (parentId) {
14948 if (!parentId) return;
14949 childrenMap[parentId].sort(function (a, b) {
14950 var rowA = rowMap[a];
14951 var rowB = rowMap[b];
14952 if (!currentSortKey) {
14953 return Number(a) - Number(b);
14954 }
14955 var valueA = rowSortValue(rowA, currentSortKey);
14956 var valueB = rowSortValue(rowB, currentSortKey);
14957 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
14958 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
14959 var fallbackA = rowSortValue(rowA, "name");
14960 var fallbackB = rowSortValue(rowB, "name");
14961 if (fallbackA < fallbackB) return -1;
14962 if (fallbackA > fallbackB) return 1;
14963 return Number(a) - Number(b);
14964 });
14965 });
14966
14967 var orderedIds = [];
14968 function pushChildren(parentId) {
14969 (childrenMap[parentId] || []).forEach(function (childId) {
14970 orderedIds.push(childId);
14971 pushChildren(childId);
14972 });
14973 }
14974
14975 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
14976 orderedIds.push(topId);
14977 pushChildren(topId);
14978 });
14979
14980 orderedIds.forEach(function (id) {
14981 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
14982 });
14983 updateSortButtons();
14984 }
14985
14986 function updateLanguageButtons() {
14987 languageButtons.forEach(function (button) {
14988 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
14989 var isActive = languageValue === activeLanguage;
14990 button.classList.toggle("active", isActive);
14991 });
14992 }
14993
14994 function rowSelfMatches(row) {
14995 var kind = row.getAttribute("data-kind");
14996 var status = row.getAttribute("data-status");
14997 var language = (row.getAttribute("data-language") || "").toLowerCase();
14998 var name = row.getAttribute("data-name-lower") || "";
14999 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
15000 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
15001 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
15002 var passesLanguage = !activeLanguage || language === activeLanguage;
15003 return passesFilter && passesSearch && passesLanguage;
15004 }
15005
15006 function hasMatchingDescendant(rowId) {
15007 return (childRows[rowId] || []).some(function (childId) {
15008 var childRow = rowById(childId);
15009 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
15010 });
15011 }
15012
15013 function rowMatches(row) {
15014 if (rowSelfMatches(row)) return true;
15015 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
15016 }
15017
15018 function resetViewState() {
15019 activeFilter = "all";
15020 activeLanguage = "";
15021 searchTerm = "";
15022 currentSortKey = null;
15023 currentSortOrder = "asc";
15024 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
15025 if (searchInput) searchInput.value = "";
15026 if (filterSelect) filterSelect.value = "all";
15027 updateLanguageButtons();
15028 }
15029
15030 function applyVisibility() {
15031 rows.forEach(function (row) {
15032 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
15033 row.classList.toggle("hidden-by-filter", !visible);
15034 row.style.display = visible ? "grid" : "none";
15035 });
15036 buttons.forEach(function (button) {
15037 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
15038 });
15039 if (filterSelect) filterSelect.value = activeFilter;
15040 }
15041
15042 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
15043 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
15044 var originalStats = {};
15045 buttons.forEach(function (btn) {
15046 var f = btn.getAttribute('data-filter');
15047 var v = btn.querySelector('.scope-stat-value');
15048 if (f && v) originalStats[f] = v.textContent;
15049 });
15050
15051 function applySubmoduleStats(statsJson) {
15052 try {
15053 var s = JSON.parse(statsJson);
15054 buttons.forEach(function (btn) {
15055 var f = btn.getAttribute('data-filter');
15056 var v = btn.querySelector('.scope-stat-value');
15057 if (!v) return;
15058 if (f === 'dir') v.textContent = s.dirs;
15059 else if (f === 'file') v.textContent = s.files;
15060 else if (f === 'supported') v.textContent = s.supported;
15061 else if (f === 'skipped') v.textContent = s.skipped;
15062 else if (f === 'unsupported') v.textContent = s.unsupported;
15063 });
15064 } catch (e) {}
15065 }
15066
15067 function restoreBaseRepoStats() {
15068 buttons.forEach(function (btn) {
15069 var f = btn.getAttribute('data-filter');
15070 var v = btn.querySelector('.scope-stat-value');
15071 if (v && originalStats[f]) v.textContent = originalStats[f];
15072 });
15073 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
15074 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
15075 }
15076
15077 submoduleChips.forEach(function (chip) {
15078 chip.addEventListener('click', function () {
15079 var statsJson = chip.getAttribute('data-sub-stats');
15080 if (!statsJson) return;
15081 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
15082 chip.classList.add('active');
15083 applySubmoduleStats(statsJson);
15084 if (baseRepoBtn) baseRepoBtn.style.display = '';
15085 });
15086 });
15087
15088 if (baseRepoBtn) {
15089 baseRepoBtn.addEventListener('click', function () {
15090 restoreBaseRepoStats();
15091 resetViewState();
15092 sortSiblingRows();
15093 applyVisibility();
15094 });
15095 }
15096
15097 buttons.forEach(function (button) {
15098 button.addEventListener("click", function () {
15099 var filterValue = button.getAttribute("data-filter") || "all";
15100 if (filterValue === "reset-view") {
15101 restoreBaseRepoStats();
15102 resetViewState();
15103 sortSiblingRows();
15104 applyVisibility();
15105 return;
15106 }
15107 activeFilter = filterValue;
15108 applyVisibility();
15109 });
15110 });
15111
15112 rows.forEach(function (row) {
15113 updateToggleGlyph(row);
15114 var toggle = row.querySelector(".tree-toggle");
15115 if (toggle) {
15116 toggle.addEventListener("click", function () {
15117 var expanded = row.getAttribute("data-expanded") !== "false";
15118 row.setAttribute("data-expanded", expanded ? "false" : "true");
15119 updateToggleGlyph(row);
15120 applyVisibility();
15121 });
15122 }
15123 });
15124
15125 actionButtons.forEach(function (button) {
15126 button.addEventListener("click", function () {
15127 var action = button.getAttribute("data-explorer-action");
15128 if (action === "expand-all") {
15129 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
15130 } else if (action === "collapse-all") {
15131 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
15132 } else if (action === "clear-filters") {
15133 resetViewState();
15134 }
15135 sortSiblingRows();
15136 applyVisibility();
15137 });
15138 });
15139
15140 if (filterSelect) {
15141 filterSelect.addEventListener("change", function () {
15142 activeFilter = filterSelect.value || "all";
15143 applyVisibility();
15144 });
15145 }
15146
15147 languageButtons.forEach(function (button) {
15148 button.addEventListener("click", function () {
15149 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
15150 updateLanguageButtons();
15151 applyVisibility();
15152 });
15153 });
15154
15155 sortButtons.forEach(function (button) {
15156 button.addEventListener("click", function () {
15157 var sortKey = button.getAttribute("data-sort-key");
15158 if (currentSortKey === sortKey) {
15159 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
15160 } else {
15161 currentSortKey = sortKey;
15162 currentSortOrder = "asc";
15163 }
15164 sortSiblingRows();
15165 applyVisibility();
15166 });
15167 });
15168
15169 if (searchInput) {
15170 searchInput.addEventListener("input", function () {
15171 searchTerm = searchInput.value.trim().toLowerCase();
15172 applyVisibility();
15173 });
15174 }
15175
15176 updateLanguageButtons();
15177 sortSiblingRows();
15178 applyVisibility();
15179 }
15180
15181 function loadPreview() {
15182 if (!previewPanel || !pathInput) return;
15183 if (GIT_MODE) {
15184 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>';
15185 return;
15186 }
15187 var path = pathInput.value.trim();
15188 var zeroWarn = document.getElementById('zero-files-warning');
15189 if (!path) {
15190 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
15191 if (zeroWarn) zeroWarn.style.display = 'none';
15192 return;
15193 }
15194 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
15195 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
15196 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
15197 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
15198 var myGen = ++_previewGen;
15199 var _prevMsgs = [
15200 'Scanning directory structure…',
15201 'Detecting file types…',
15202 'Applying include / exclude filters…',
15203 'Estimating file counts…',
15204 'Building scope preview…',
15205 'Almost there…'
15206 ];
15207 var _prevMsgIdx = 0;
15208 var _prevStart = Date.now();
15209 previewPanel.innerHTML =
15210 '<div class="preview-loading">' +
15211 '<div class="preview-spinner"></div>' +
15212 '<div class="preview-loading-text">' +
15213 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
15214 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
15215 '</div></div>';
15216 var _sizeTextEl = document.getElementById('project-size-text');
15217 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
15218 window._previewInterval = setInterval(function() {
15219 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
15220 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
15221 var ml = document.getElementById('plm');
15222 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
15223 }, 1500);
15224 window._previewElapsedTimer = setInterval(function() {
15225 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
15226 var el = document.getElementById('ple');
15227 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
15228 }, 1000);
15229 var previewUrl = "/preview?path=" + encodeURIComponent(path)
15230 + "&include_globs=" + encodeURIComponent(includeValue)
15231 + "&exclude_globs=" + encodeURIComponent(excludeValue);
15232 fetch(previewUrl)
15233 .then(function (response) { return response.text(); })
15234 .then(function (html) {
15235 if (myGen !== _previewGen) return;
15236 clearInterval(window._previewInterval); window._previewInterval = null;
15237 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
15238 previewPanel.innerHTML = html;
15239 attachPreviewInteractions();
15240 syncPythonVisibility();
15241 updateReview();
15242 setTimeout(collapseLanguagePills, 50);
15243 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
15244 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
15245 var sizeText = document.getElementById('project-size-text');
15246 var sizeBtn = document.getElementById('project-size-btn');
15247 // In server mode with upload sizes available, keep the compressed/original pair.
15248 if (SERVER_MODE && window._lastUploadSizes) {
15249 var us = window._lastUploadSizes;
15250 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
15251 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
15252 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
15253 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
15254 } else if (sizeText && projectSize) {
15255 sizeText.textContent = 'Project size: ' + projectSize;
15256 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
15257 } else if (sizeText) {
15258 sizeText.textContent = 'Project size: —';
15259 }
15260 if (zeroWarn) {
15261 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
15262 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
15263 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
15264 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
15265 if (supportedCount === 0 && fileCount > 0) {
15266 zeroWarn.textContent = '⚠ Warning: No supported source files detected—this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
15267 zeroWarn.style.display = '';
15268 } else {
15269 zeroWarn.style.display = 'none';
15270 }
15271 }
15272 })
15273 .catch(function (err) {
15274 if (myGen !== _previewGen) return;
15275 clearInterval(window._previewInterval); window._previewInterval = null;
15276 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
15277 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
15278 });
15279 }
15280
15281 function pickDirectory(targetInput, kind) {
15282 if (SERVER_MODE) {
15283 if (kind === 'output') {
15284 showBannerToast(
15285 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
15286 false,
15287 { top: true, icon: '📁' }
15288 );
15289 return;
15290 }
15291 var inputEl = kind === 'coverage'
15292 ? document.getElementById('cov-upload-input')
15293 : document.getElementById('dir-upload-input');
15294 if (!inputEl) return;
15295 inputEl.onchange = function () {
15296 var files = inputEl.files;
15297 if (!files || files.length === 0) return;
15298 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
15299 if (browseBtn) browseBtn.disabled = true;
15300
15301 function fileToBase64(file) {
15302 return new Promise(function (resolve, reject) {
15303 var reader = new FileReader();
15304 reader.onload = function () {
15305 var b64 = reader.result.split(',')[1];
15306 resolve(b64);
15307 };
15308 reader.onerror = reject;
15309 reader.readAsDataURL(file);
15310 });
15311 }
15312
15313 if (kind === 'coverage') {
15314 var f = files[0];
15315 if (previewPanel && targetInput === pathInput)
15316 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
15317 fileToBase64(f).then(function (b64) {
15318 return fetch('/api/upload-file', {
15319 method: 'POST',
15320 headers: { 'Content-Type': 'application/json' },
15321 body: JSON.stringify({ filename: f.name, content: b64 })
15322 }).then(function (r) { return r.json(); });
15323 })
15324 .then(function (d) {
15325 if (d && d.tmp_path) {
15326 if (coverageInput) coverageInput.value = d.tmp_path;
15327 setCovStatus('idle');
15328 } else if (d && d.error) { showBannerToast(d.error, true); }
15329 })
15330 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
15331 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
15332 } else {
15333 // ── Filter to source-code files only ─────────────────────────
15334 // Binary, generated, and dependency files (node_modules, .git,
15335 // build artifacts) are skipped so they are never uploaded.
15336 var CODE_EXTS = new Set([
15337 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15338 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15339 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15340 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15341 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15342 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
15343 'tf','hcl','proto','thrift','avsc','graphql','gql'
15344 ]);
15345 var codeFiles = [];
15346 for (var i = 0; i < files.length; i++) {
15347 var f = files[i];
15348 var name = f.name;
15349 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
15350 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
15351 codeFiles.push(f); continue;
15352 }
15353 var dot = name.lastIndexOf('.');
15354 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
15355 }
15356 // Collect specific .git metadata files for server-side git detection.
15357 // These have no source extension so they are excluded by the loop above,
15358 // but the server needs them to read branch/commit/author without running git.
15359 var gitMetaFiles = [];
15360 for (var i = 0; i < files.length; i++) {
15361 var f = files[i];
15362 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
15363 var gitIdx = rp.indexOf('/.git/');
15364 if (gitIdx < 0) continue;
15365 var gitRel = rp.slice(gitIdx + 1);
15366 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
15367 gitRel === '.git/logs/HEAD' ||
15368 gitRel.startsWith('.git/refs/heads/') ||
15369 gitRel.startsWith('.git/refs/tags/')) {
15370 gitMetaFiles.push(f);
15371 }
15372 }
15373 var uploadFiles = codeFiles.concat(gitMetaFiles);
15374 var total = files.length;
15375 var kept = codeFiles.length;
15376 if (kept === 0) {
15377 if (previewPanel && targetInput === pathInput)
15378 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
15379 if (browseBtn) browseBtn.disabled = false;
15380 inputEl.value = '';
15381 return;
15382 }
15383
15384 // ── Helper: apply upload result to UI ────────────────────────
15385 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
15386 function applyUploadResult(tmpPath, sizes) {
15387 targetInput.value = tmpPath;
15388 scrollInputToEnd(targetInput);
15389 if (sizes && SERVER_MODE) {
15390 window._lastUploadSizes = sizes;
15391 // Immediately show both sizes before preview loads.
15392 var sizeText = document.getElementById('project-size-text');
15393 var sizeBtn = document.getElementById('project-size-btn');
15394 if (sizeText) {
15395 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15396 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15397 }
15398 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15399 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15400 }
15401 if (targetInput === pathInput) {
15402 updateReportTitleFromPath();
15403 autoSetOutputDir(tmpPath);
15404 fetchProjectHistory(tmpPath);
15405 loadPreview();
15406 suggestCoverageFile(tmpPath);
15407 }
15408 updateReview();
15409 if (browseBtn) browseBtn.disabled = false;
15410 inputEl.value = '';
15411 }
15412
15413 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
15414 if (typeof CompressionStream !== 'undefined') {
15415 if (previewPanel && targetInput === pathInput)
15416 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15417
15418 // Build a minimal POSIX ustar tar header for a single file entry.
15419 function buildUstarHeader(filePath, fileSize) {
15420 var BLOCK = 512;
15421 var hdr = new Uint8Array(BLOCK);
15422 var enc = new TextEncoder();
15423 function wStr(off, len, s) {
15424 var b = enc.encode(s);
15425 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
15426 }
15427 function wOct(off, len, val) {
15428 var s = val.toString(8);
15429 while (s.length < len - 1) s = '0' + s;
15430 wStr(off, len, s + '\0');
15431 }
15432 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
15433 var name = filePath, prefix = '';
15434 if (filePath.length > 99) {
15435 var split = filePath.lastIndexOf('/', 154);
15436 if (split > 0 && filePath.length - split - 1 <= 99) {
15437 prefix = filePath.substring(0, split);
15438 name = filePath.substring(split + 1);
15439 } else { name = filePath.substring(0, 99); }
15440 }
15441 wStr(0, 100, name); // name
15442 wOct(100, 8, 0o000644); // mode
15443 wOct(108, 8, 0); // uid
15444 wOct(116, 8, 0); // gid
15445 wOct(124, 12, fileSize); // size
15446 wOct(136, 12, 0); // mtime (epoch)
15447 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
15448 hdr[156] = 48; // type flag '0' = regular file
15449 wStr(157, 100, ''); // linkname
15450 wStr(257, 6, 'ustar'); // magic
15451 wStr(263, 2, '00'); // version
15452 wStr(265, 32, ''); // uname
15453 wStr(297, 32, ''); // gname
15454 wOct(329, 8, 0); // devmajor
15455 wOct(337, 8, 0); // devminor
15456 wStr(345, 155, prefix); // prefix
15457 // Compute checksum (sum of all bytes, placeholder = 32).
15458 var chk = 0;
15459 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15460 var cs = chk.toString(8);
15461 while (cs.length < 6) cs = '0' + cs;
15462 wStr(148, 8, cs + '\0 ');
15463 return hdr;
15464 }
15465
15466 // Build tar.gz one file at a time, piping through CompressionStream.
15467 // RAM usage = compressed output buffer + one file at a time.
15468 (async function () {
15469 try {
15470 var BLOCK = 512;
15471 var cs = new CompressionStream('gzip');
15472 var writer = cs.writable.getWriter();
15473 var chunks = [];
15474 var reader = cs.readable.getReader();
15475 var collecting = (async function () {
15476 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
15477 })();
15478
15479 for (var i = 0; i < uploadFiles.length; i++) {
15480 var file = uploadFiles[i];
15481 var path = file.webkitRelativePath || file.name;
15482 var buf = await file.arrayBuffer();
15483 var data = new Uint8Array(buf);
15484 // Header block
15485 await writer.write(buildUstarHeader(path, data.length));
15486 // Data padded to 512-byte boundary
15487 if (data.length > 0) {
15488 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
15489 var block = new Uint8Array(padded);
15490 block.set(data);
15491 await writer.write(block);
15492 }
15493 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
15494 if (previewPanel && targetInput === pathInput)
15495 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15496 }
15497 }
15498 // End-of-archive: two 512-byte zero blocks
15499 await writer.write(new Uint8Array(BLOCK * 2));
15500 await writer.close();
15501 await collecting;
15502
15503 var blob = new Blob(chunks, { type: 'application/gzip' });
15504 var sizeMB = (blob.size / 1048576).toFixed(1);
15505 if (previewPanel && targetInput === pathInput)
15506 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
15507
15508 var resp = await fetch('/api/upload-tarball', {
15509 method: 'POST',
15510 headers: { 'Content-Type': 'application/gzip' },
15511 body: blob
15512 });
15513 var d = await resp.json();
15514 if (d && d.tmp_path) {
15515 applyUploadResult(d.tmp_path, {
15516 compressed_bytes: d.compressed_bytes || 0,
15517 original_bytes: d.original_bytes || 0
15518 });
15519 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15520 } catch (e) {
15521 showBannerToast('Upload failed: ' + String(e), true);
15522 if (browseBtn) browseBtn.disabled = false;
15523 inputEl.value = '';
15524 }
15525 })();
15526
15527 } else {
15528 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
15529 // Used only on browsers that lack CompressionStream (pre-2023).
15530 var BATCH = 200;
15531 var batches = [];
15532 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
15533 var totalBatches = batches.length;
15534 if (previewPanel && targetInput === pathInput)
15535 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
15536
15537 function sendBatch(idx, currentUploadId, lastTmpPath) {
15538 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
15539 if (previewPanel && targetInput === pathInput && totalBatches > 1)
15540 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
15541 Promise.all(batches[idx].map(function (file) {
15542 return fileToBase64(file).then(function (b64) {
15543 return { path: file.webkitRelativePath || file.name, content: b64 };
15544 });
15545 })).then(function (fileList) {
15546 var body = { files: fileList };
15547 if (currentUploadId) body.upload_id = currentUploadId;
15548 return fetch('/api/upload-directory', {
15549 method: 'POST', headers: { 'Content-Type': 'application/json' },
15550 body: JSON.stringify(body)
15551 }).then(function (r) { return r.json(); });
15552 }).then(function (d) {
15553 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
15554 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15555 }).catch(function (e) {
15556 showBannerToast('Upload failed: ' + String(e), true);
15557 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
15558 });
15559 }
15560 sendBatch(0, null, '');
15561 }
15562 }
15563 };
15564 inputEl.click();
15565 return;
15566 }
15567
15568 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
15569 if (browseButton) browseButton.disabled = true;
15570
15571 if (previewPanel && targetInput === pathInput) {
15572 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
15573 }
15574
15575 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
15576 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
15577 .then(function (data) {
15578 if (data && data.selected_path) {
15579 targetInput.value = data.selected_path;
15580 scrollInputToEnd(targetInput);
15581
15582 if (targetInput === pathInput) {
15583 updateReportTitleFromPath();
15584 autoSetOutputDir(data.selected_path);
15585 fetchProjectHistory(data.selected_path);
15586 loadPreview();
15587 suggestCoverageFile(data.selected_path);
15588 }
15589
15590 updateReview();
15591 } else if (targetInput === pathInput) {
15592 loadPreview();
15593 }
15594 })
15595 .catch(function () {
15596 window.alert("Directory picker request failed.");
15597 if (previewPanel && targetInput === pathInput) {
15598 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
15599 }
15600 })
15601 .finally(function () {
15602 if (browseButton) browseButton.disabled = false;
15603 });
15604 }
15605
15606 if (themeToggle) {
15607 themeToggle.addEventListener("click", function () {
15608 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
15609 applyTheme(nextTheme);
15610 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
15611 });
15612 }
15613
15614 stepButtons.forEach(function (button) {
15615 button.addEventListener("click", function () {
15616 setStep(Number(button.getAttribute("data-step-target")));
15617 });
15618 });
15619
15620 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
15621 button.addEventListener("click", function () {
15622 setStep(Number(button.getAttribute("data-step-target")) || 1);
15623 });
15624 });
15625
15626 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
15627 button.addEventListener("click", function () {
15628 updateReview();
15629 setStep(Number(button.getAttribute("data-next")));
15630 });
15631 });
15632
15633 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
15634 button.addEventListener("click", function () {
15635 setStep(Number(button.getAttribute("data-prev")));
15636 });
15637 });
15638
15639 document.addEventListener("keydown", function (e) {
15640 var tag = (document.activeElement || {}).tagName || "";
15641 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
15642 if (e.altKey || e.ctrlKey || e.metaKey) return;
15643 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
15644 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
15645 });
15646
15647 if (useSamplePath) {
15648 useSamplePath.addEventListener("click", function () {
15649 pathInput.value = "tests/fixtures/basic";
15650 updateReportTitleFromPath();
15651 autoSetOutputDir("tests/fixtures/basic");
15652 loadPreview();
15653 suggestCoverageFile("tests/fixtures/basic");
15654 });
15655 }
15656
15657 if (useDefaultOutput) {
15658 useDefaultOutput.addEventListener("click", function () {
15659 delete outputDirInput.dataset.userEdited;
15660 autoSetOutputDir(pathInput ? pathInput.value : "");
15661 updateReview();
15662 });
15663 }
15664
15665 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
15666 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
15667
15668 // ── Drag-and-drop directory upload (server mode only) ─────────────────
15669 // Dropping a folder onto the path field bypasses Chrome's
15670 // "Upload X files to this site?" confirmation dialog.
15671 async function readDirRecursively(dirEntry, basePath) {
15672 var reader = dirEntry.createReader();
15673 var all = [];
15674 for (;;) {
15675 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
15676 if (!batch.length) break;
15677 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
15678 }
15679 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
15680 var out = [];
15681 for (var i = 0; i < all.length; i++) {
15682 var sub = all[i];
15683 if (sub.isFile) {
15684 var f = await new Promise(function(res) { sub.file(res); });
15685 out.push({ file: f, path: basePath + '/' + sub.name });
15686 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
15687 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
15688 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
15689 }
15690 }
15691 return out;
15692 }
15693
15694 function setupPathDropZone() {
15695 if (!SERVER_MODE || !pathInput) return;
15696 var CODE_EXTS = new Set([
15697 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15698 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15699 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15700 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15701 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15702 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
15703 ]);
15704 pathInput.addEventListener('dragover', function(e) {
15705 e.preventDefault();
15706 pathInput.classList.add('drag-over');
15707 });
15708 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
15709 pathInput.addEventListener('drop', function(e) {
15710 e.preventDefault();
15711 pathInput.classList.remove('drag-over');
15712 var items = e.dataTransfer.items;
15713 if (!items || !items.length) return;
15714 var dirEntry = null;
15715 for (var i = 0; i < items.length; i++) {
15716 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
15717 if (entry && entry.isDirectory) { dirEntry = entry; break; }
15718 }
15719 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
15720 var btn = browsePath;
15721 if (btn) btn.disabled = true;
15722 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
15723
15724 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
15725 var total = allEntries.length;
15726 var codeEntries = allEntries.filter(function(e) {
15727 var n = e.file.name;
15728 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
15729 var dot = n.lastIndexOf('.');
15730 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
15731 });
15732 var kept = codeEntries.length;
15733 if (kept === 0) {
15734 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
15735 if (btn) btn.disabled = false; return;
15736 }
15737
15738 function finish(tmpPath, sizes) {
15739 pathInput.value = tmpPath;
15740 scrollInputToEnd(pathInput);
15741 if (sizes) {
15742 window._lastUploadSizes = sizes;
15743 var sizeText = document.getElementById('project-size-text');
15744 var sizeBtn = document.getElementById('project-size-btn');
15745 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15746 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15747 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15748 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15749 }
15750 updateReportTitleFromPath();
15751 autoSetOutputDir(tmpPath);
15752 fetchProjectHistory(tmpPath);
15753 loadPreview();
15754 suggestCoverageFile(tmpPath);
15755 updateReview();
15756 if (btn) btn.disabled = false;
15757 }
15758
15759 if (typeof CompressionStream === 'undefined') {
15760 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
15761 if (btn) btn.disabled = false; return;
15762 }
15763
15764 try {
15765 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15766 var BLOCK = 512;
15767 var cs = new CompressionStream('gzip');
15768 var wtr = cs.writable.getWriter();
15769 var chunks = [];
15770 var rdr = cs.readable.getReader();
15771 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
15772
15773 function buildHdr(fp, sz) {
15774 var hdr = new Uint8Array(BLOCK);
15775 var enc = new TextEncoder();
15776 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]; }
15777 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
15778 var nm = fp, pfx = '';
15779 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); } }
15780 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
15781 for (var i = 148; i < 156; i++) hdr[i] = 32;
15782 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);
15783 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15784 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
15785 return hdr;
15786 }
15787
15788 for (var i = 0; i < codeEntries.length; i++) {
15789 var ce = codeEntries[i];
15790 var buf = await ce.file.arrayBuffer();
15791 var data = new Uint8Array(buf);
15792 await wtr.write(buildHdr(ce.path, data.length));
15793 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
15794 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
15795 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15796 }
15797 await wtr.write(new Uint8Array(BLOCK * 2));
15798 await wtr.close();
15799 await collecting;
15800
15801 var blob = new Blob(chunks, { type: 'application/gzip' });
15802 var sizeMB = (blob.size / 1048576).toFixed(1);
15803 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
15804 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
15805 var d = await resp.json();
15806 if (d && d.tmp_path) {
15807 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
15808 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
15809 } catch (err) {
15810 showBannerToast('Upload failed: ' + String(err), true);
15811 if (btn) btn.disabled = false;
15812 }
15813 }).catch(function(err) {
15814 showBannerToast('Could not read folder: ' + String(err), true);
15815 if (btn) btn.disabled = false;
15816 });
15817 });
15818 }
15819 setupPathDropZone();
15820 if (browseCoverage) {
15821 browseCoverage.addEventListener("click", function () {
15822 pickDirectory(coverageInput || pathInput, "coverage");
15823 });
15824 }
15825
15826 function setCovStatus(state, opts) {
15827 if (!covScanStatus) return;
15828 opts = opts || {};
15829 covScanStatus.className = "cov-scan-status cov-scan-" + state;
15830 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
15831 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>';
15832 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>';
15833 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>';
15834 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>';
15835 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
15836 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
15837 if (state === "scanning") {
15838 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
15839 } else if (state === "found") {
15840 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15841 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
15842 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
15843 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
15844 } else if (state === "hint") {
15845 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15846 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
15847 html += '<div class="cov-scan-sub">Generate a report with your test framework\'s coverage tool, then browse to the output file. Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
15848 } else if (state === "none") {
15849 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
15850 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
15851 }
15852 html += '</div></div>';
15853 covScanStatus.innerHTML = html;
15854 if (state === "found") {
15855 var useBtn = covScanStatus.querySelector(".cov-scan-use");
15856 if (useBtn) useBtn.addEventListener("click", function () {
15857 if (coverageInput) coverageInput.value = "";
15858 covAutoFilled = false;
15859 setCovStatus("idle");
15860 });
15861 }
15862 }
15863
15864 function suggestCoverageFile(projectPath) {
15865 if (!coverageInput || !covScanStatus) return;
15866 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15867 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
15868 clearTimeout(coverageSuggestTimer);
15869 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
15870 setCovStatus("scanning");
15871 coverageSuggestTimer = setTimeout(function () {
15872 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
15873 .then(function (r) { return r.json(); })
15874 .then(function (d) {
15875 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15876 if (!d) { setCovStatus("none"); return; }
15877 if (d.found) {
15878 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
15879 setCovStatus("found", { found: d.found, tool: d.tool });
15880 } else if (d.tool && d.hint) {
15881 setCovStatus("hint", { tool: d.tool, hint: d.hint });
15882 } else {
15883 setCovStatus("none");
15884 }
15885 })
15886 .catch(function () { setCovStatus("idle"); });
15887 }, 600);
15888 }
15889
15890 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
15891
15892 if (coverageInput) coverageInput.addEventListener("input", function () {
15893 covAutoFilled = false;
15894 if (!this.value.trim()) setCovStatus("idle");
15895 });
15896
15897 // ── Language pill overflow: collapse to "+N more" chip ─────────────
15898 function collapseLanguagePills() {
15899 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
15900 rows.forEach(function(row) {
15901 // Remove any previous overflow chip
15902 var prev = row.querySelector('.lang-overflow-chip');
15903 if (prev) prev.remove();
15904 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
15905 pills.forEach(function(p) { p.style.display = ''; });
15906 if (!pills.length) return;
15907
15908 // Measure after restoring all pills
15909 var containerRight = row.getBoundingClientRect().right;
15910 var hidden = [];
15911 for (var i = pills.length - 1; i >= 1; i--) {
15912 var rect = pills[i].getBoundingClientRect();
15913 if (rect.right > containerRight + 2) {
15914 hidden.unshift(pills[i]);
15915 pills[i].style.display = 'none';
15916 } else {
15917 break;
15918 }
15919 }
15920
15921 if (hidden.length) {
15922 var chip = document.createElement('button');
15923 chip.type = 'button';
15924 chip.className = 'language-pill lang-overflow-chip';
15925 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
15926 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
15927 row.appendChild(chip);
15928 }
15929 });
15930 }
15931
15932 // Run after preview loads (preview panel populates language pills)
15933 var _origLoadPreviewCb = window.__previewLoaded;
15934 document.addEventListener('previewLoaded', collapseLanguagePills);
15935 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
15936 setTimeout(collapseLanguagePills, 400);
15937
15938 // ── Project history & output dir auto-set ──────────────────────────
15939 var wsOutputRoot = document.getElementById("ws-output-root");
15940 var wsScanCount = document.getElementById("ws-scan-count");
15941 var wsLastScan = document.getElementById("ws-last-scan");
15942 var historyBadge = document.getElementById("path-history-badge");
15943 var historyTimer = null;
15944
15945 var wsOutputLink = document.getElementById("ws-output-link");
15946 function syncStripOutputRoot() {
15947 var val = outputDirInput ? outputDirInput.value : "";
15948 var display = val || "project/sloc";
15949 if (wsOutputRoot) wsOutputRoot.textContent = display;
15950 if (wsOutputLink) wsOutputLink.dataset.folder = val;
15951 }
15952
15953 function scrollInputToEnd(input) {
15954 if (!input) return;
15955 // Defer so the DOM has the new value before we measure scroll width.
15956 requestAnimationFrame(function () {
15957 input.scrollLeft = input.scrollWidth;
15958 input.selectionStart = input.selectionEnd = input.value.length;
15959 });
15960 }
15961
15962 function autoSetOutputDir(projectPath) {
15963 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
15964 if (GIT_MODE && GIT_OUTPUT_DIR) {
15965 outputDirInput.value = GIT_OUTPUT_DIR;
15966 scrollInputToEnd(outputDirInput);
15967 syncStripOutputRoot();
15968 updateReview();
15969 return;
15970 }
15971 if (!projectPath || !projectPath.trim()) return;
15972 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
15973 outputDirInput.value = cleaned + "/sloc";
15974 scrollInputToEnd(outputDirInput);
15975 syncStripOutputRoot();
15976 updateReview();
15977 }
15978
15979 var wsBranch = document.getElementById("ws-branch");
15980
15981 function fetchProjectHistory(projectPath) {
15982 if (!projectPath || !projectPath.trim()) {
15983 if (wsScanCount) wsScanCount.textContent = "—";
15984 if (wsLastScan) wsLastScan.textContent = "—";
15985 if (wsBranch) wsBranch.textContent = "—";
15986 if (historyBadge) historyBadge.style.display = "none";
15987 return;
15988 }
15989 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
15990 .then(function (r) { return r.ok ? r.json() : null; })
15991 .then(function (data) {
15992 if (!data) return;
15993 var countStr = data.scan_count > 0
15994 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
15995 : "never";
15996 var tsStr = data.last_scan_timestamp
15997 ? data.last_scan_timestamp.replace(" UTC","")
15998 : "—";
15999 if (wsScanCount) wsScanCount.textContent = countStr;
16000 if (wsLastScan) wsLastScan.textContent = tsStr;
16001 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
16002 if (data.scan_count > 0) {
16003 if (historyBadge) {
16004 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
16005 historyBadge.textContent = data.scan_count + " previous scan" +
16006 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
16007 "Last: " + (data.last_scan_timestamp || "—") +
16008 " — " + (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.";
16009 historyBadge.className = "path-history-badge found";
16010 historyBadge.style.display = "";
16011 }
16012 } else {
16013 if (historyBadge) historyBadge.style.display = "none";
16014 }
16015 })
16016 .catch(function () {});
16017 }
16018
16019 function onPathChange() {
16020 var val = pathInput ? pathInput.value : "";
16021 // Discard stale upload sizes when the user edits the path manually.
16022 window._lastUploadSizes = null;
16023 updateReportTitleFromPath();
16024 autoSetOutputDir(val);
16025 updateSidebarSummary();
16026 clearTimeout(historyTimer);
16027 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
16028 if (previewTimer) clearTimeout(previewTimer);
16029 previewTimer = setTimeout(loadPreview, 280);
16030 suggestCoverageFile(val);
16031 }
16032
16033 if (pathInput) {
16034 pathInput.addEventListener("input", onPathChange);
16035 }
16036
16037 if (outputDirInput) {
16038 outputDirInput.addEventListener("input", function () {
16039 outputDirInput.dataset.userEdited = "1";
16040 syncStripOutputRoot();
16041 updateReview();
16042 });
16043 }
16044
16045 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
16046 if (!node) return;
16047 node.addEventListener("input", function () {
16048 updateReview();
16049 if (previewTimer) clearTimeout(previewTimer);
16050 previewTimer = setTimeout(loadPreview, 280);
16051 });
16052 });
16053
16054 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
16055 var node = document.getElementById(id);
16056 if (node) node.addEventListener("change", updateReview);
16057 });
16058
16059 if (reportTitleInput) {
16060 reportTitleInput.addEventListener("input", function () {
16061 reportTitleTouched = reportTitleInput.value.trim().length > 0;
16062 updateReportTitleFromPath();
16063 updateReview();
16064 });
16065 }
16066
16067 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
16068 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
16069 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
16070 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
16071
16072 if (coverageInput) {
16073 coverageInput.addEventListener("input", function () {
16074 if (coverageInput.value.trim()) setCovStatus("idle");
16075 });
16076 }
16077
16078 if (form && loading && submitButton) {
16079 form.addEventListener("submit", function (e) {
16080 e.preventDefault();
16081 submitButton.disabled = true;
16082 submitButton.textContent = "Scanning...";
16083 startAsyncAnalysis(new FormData(form));
16084 });
16085 }
16086
16087 function openPath(folder) {
16088 if (!folder) return;
16089 fetch('/open-path?path=' + encodeURIComponent(folder))
16090 .then(function (r) { return r.json(); })
16091 .then(function (d) {
16092 if (d && d.server_mode_disabled)
16093 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16094 })
16095 .catch(function () {});
16096 }
16097
16098 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16099 btn.addEventListener('click', function () {
16100 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
16101 });
16102 });
16103
16104 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
16105 if (wsOutputLink) {
16106 wsOutputLink.addEventListener('click', function () {
16107 openPath(wsOutputLink.dataset.folder || '');
16108 });
16109 }
16110
16111 loadSavedTheme();
16112 updateMixedPolicyUI();
16113 updatePythonDocstringUI();
16114 applyScanPreset();
16115 updatePresetDescriptions();
16116 applyArtifactPreset();
16117 updateReview();
16118 updateScrollProgress(); // initialise bar to 0% (step 1)
16119 window.addEventListener("scroll", updateScrollProgress, { passive: true });
16120 onPathChange(); // seed output dir, history badge, and preview from initial path
16121 updateStepNav(1);
16122
16123 // Restore step from URL hash on initial load (e.g., back-forward cache)
16124 (function() {
16125 var hashMatch = location.hash.match(/^#step([1-4])$/);
16126 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
16127 })();
16128
16129 (function randomizeWatermarks() {
16130 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
16131 if (!wms.length) return;
16132 var placed = [];
16133 function tooClose(top, left) {
16134 for (var i = 0; i < placed.length; i++) {
16135 var dt = Math.abs(placed[i][0] - top);
16136 var dl = Math.abs(placed[i][1] - left);
16137 if (dt < 16 && dl < 12) return true;
16138 }
16139 return false;
16140 }
16141 function pick(leftBand) {
16142 for (var attempt = 0; attempt < 50; attempt++) {
16143 var top = Math.random() * 88 + 2;
16144 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16145 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16146 }
16147 var top = Math.random() * 88 + 2;
16148 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16149 placed.push([top, left]);
16150 return [top, left];
16151 }
16152 var half = Math.floor(wms.length / 2);
16153 wms.forEach(function (img, i) {
16154 var pos = pick(i < half);
16155 var size = Math.floor(Math.random() * 80 + 110);
16156 var rot = (Math.random() * 360).toFixed(1);
16157 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
16158 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;
16159 });
16160 })();
16161
16162 (function spawnCodeParticles() {
16163 var container = document.getElementById('code-particles');
16164 if (!container) return;
16165 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'];
16166 for (var i = 0; i < 38; i++) {
16167 (function(idx) {
16168 var el = document.createElement('span');
16169 el.className = 'code-particle';
16170 el.textContent = snippets[idx % snippets.length];
16171 var left = Math.random() * 94 + 2;
16172 var top = Math.random() * 88 + 6;
16173 var dur = (Math.random() * 10 + 9).toFixed(1);
16174 var delay = (Math.random() * 18).toFixed(1);
16175 var rot = (Math.random() * 26 - 13).toFixed(1);
16176 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16177 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';
16178 container.appendChild(el);
16179 })(i);
16180 }
16181 })();
16182 })();
16183 </script>
16184 <script nonce="{{ csp_nonce }}">
16185 (function () {
16186 var raw = {{ prefill_json|safe }};
16187 if (!raw || typeof raw !== 'object' || !raw.path) return;
16188 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
16189 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
16190 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
16191 setVal('path', raw.path || '');
16192 setVal('include_globs', raw.include_globs || '');
16193 setVal('exclude_globs', raw.exclude_globs || '');
16194 setVal('output_dir', raw.output_dir || '');
16195 setVal('report_title', raw.report_title || '');
16196 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
16197 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
16198 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
16199 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
16200 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
16201 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
16202 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
16203 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
16204 setChecked('generate_html', raw.generate_html !== false);
16205 setChecked('generate_pdf', !!raw.generate_pdf);
16206 // Trigger dynamic UI updates after pre-fill.
16207 setTimeout(function () {
16208 var pathEl = document.getElementById('path');
16209 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
16210 var policyEl = document.getElementById('mixed_line_policy');
16211 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
16212 }, 80);
16213 })();
16214 </script>
16215 <script nonce="{{ csp_nonce }}">
16216 (function(){
16217 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'}];
16218 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);});}
16219 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16220 function init(){
16221 var btn=document.getElementById('settings-btn');if(!btn)return;
16222 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16223 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>';
16224 document.body.appendChild(m);
16225 var g=document.getElementById('scheme-grid');
16226 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);});
16227 var cl=document.getElementById('settings-close');
16228 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);
16229 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');});
16230 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16231 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16232 }
16233 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16234 }());
16235 </script>
16236 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
16237 <div class="wb-ftip-arrow"></div>
16238 <span id="wb-ftip-text"></span>
16239 </div>
16240 <script nonce="{{ csp_nonce }}">(function(){
16241 var tip=document.getElementById('wb-ftip');
16242 var txt=document.getElementById('wb-ftip-text');
16243 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
16244 if(!tip||!txt)return;
16245 function pos(el){
16246 var r=el.getBoundingClientRect();
16247 tip.style.display='block';
16248 var tw=tip.offsetWidth;
16249 var lx=r.left+r.width/2-tw/2;
16250 if(lx<8)lx=8;
16251 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
16252 tip.style.left=lx+'px';
16253 tip.style.top=(r.bottom+8)+'px';
16254 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';}
16255 }
16256 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
16257 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
16258 el.addEventListener('mouseleave',function(){tip.style.display='none';});
16259 });
16260 window.addEventListener('blur',function(){tip.style.display='none';});
16261 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
16262 })();
16263 (function(){
16264 function fixArtifactHintSpacing(){
16265 var grid=document.querySelector('.artifact-grid');
16266 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
16267 }
16268 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
16269 }());
16270 (function(){
16271 var dot=document.getElementById('status-dot');
16272 var pingEl=document.getElementById('server-ping-ms');
16273 var tipEl=document.getElementById('server-tip-ping');
16274 var fm=document.getElementById('footer-mode');
16275 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)';}}
16276 function doPing(){
16277 var t0=performance.now();
16278 fetch('/healthz',{cache:'no-store'})
16279 .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);})
16280 .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)';}});
16281 }
16282 doPing();
16283 setInterval(doPing,5000);
16284 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
16285 })();
16286 </script>
16287 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
16288 <footer class="site-footer">
16289 local code analysis - metrics, history and reports
16290 · <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>
16291 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16292 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16293 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16294 · <a href="/api-docs" rel="noopener">REST API</a>
16295 </footer>
16296</body>
16297</html>
16298"##,
16299 ext = "html"
16300)]
16301struct IndexTemplate {
16302 version: &'static str,
16303 prefill_json: String,
16304 csp_nonce: String,
16305 git_repo: String,
16306 git_ref: String,
16307 git_label_json: String,
16308 git_output_dir_json: String,
16309 server_mode: bool,
16310}
16311
16312#[derive(Template)]
16315#[template(
16316 source = r##"
16317<!doctype html>
16318<html lang="en">
16319<head>
16320 <meta charset="utf-8">
16321 <meta name="viewport" content="width=device-width, initial-scale=1">
16322 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
16323 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16324 <style nonce="{{ csp_nonce }}">
16325 :root {
16326 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
16327 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16328 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16329 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16330 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
16331 }
16332 body.dark-theme {
16333 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
16334 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
16335 }
16336 *{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;}
16337 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16338 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16339 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16340 .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;}
16341 @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));}}
16342 .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);}
16343 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16344 .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));}
16345 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16346 .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;}
16347 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16348 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16349 @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; } }
16350 .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;}
16351 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16352 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
16353 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16354 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16355 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16356 .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;}
16357 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16358 .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);}
16359 .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;}
16360 .settings-close:hover{color:var(--text);background:var(--surface-2);}
16361 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16362 .settings-modal-body{padding:14px 16px 16px;}
16363 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16364 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16365 .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;}
16366 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16367 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16368 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16369 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16370 .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;}
16371 .tz-select:focus{border-color:var(--oxide);}
16372 .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;}
16373 .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;}
16374 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
16375 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
16376 .hero{text-align:center;margin:0 auto 18px;}
16377 .hero-logo-wrap{display:inline-block;cursor:default;}
16378 .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;}
16379 .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;}
16380 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
16381 .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;}
16382 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%);}
16383 .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;
16384 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
16385 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
16386 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;}
16387 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
16388 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
16389 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;}
16390 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
16391 .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;}
16392 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
16393 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
16394 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
16395 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
16396 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
16397 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
16398 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
16399 .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;}
16400 .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;}
16401 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
16402 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
16403 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16404 .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);}
16405 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
16406 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
16407 .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);}
16408 .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);}
16409 .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);}
16410 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
16411 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
16412 .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;}
16413 body.dark-theme .action-card-cta{color:var(--oxide);}
16414 .action-card.view .action-card-cta{color:var(--accent-2);}
16415 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
16416 .action-card.compare .action-card-cta{color:#7c3aed;}
16417 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
16418 .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);}
16419 .action-card.git-tools .action-card-cta{color:#15803d;}
16420 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
16421 .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);}
16422 .action-card.trend .action-card-cta{color:#0e7490;}
16423 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
16424 .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);}
16425 .action-card.automation .action-card-cta{color:#b45309;}
16426 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
16427 .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);}
16428 .action-card.test-metrics .action-card-cta{color:#be185d;}
16429 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
16430 .action-card:hover .action-card-cta{gap:12px;}
16431 .action-card.card-split{flex-direction:row;align-items:stretch;}
16432 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
16433 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
16434 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
16435 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
16436 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
16437 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
16438 .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;}
16439 .ac-badge.active{opacity:1;}
16440 .ac-badge.github{border-color:#555;color:#555;}
16441 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
16442 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
16443 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
16444 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
16445 body.dark-theme .ac-right-row{color:var(--muted);}
16446 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
16447 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
16448 .divider{height:1px;background:var(--line);margin:32px 0;}
16449 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
16450 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
16451 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
16452 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
16453 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
16454 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16455 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
16456 body.dark-theme .info-chip-val{color:var(--oxide);}
16457 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
16458 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
16459 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
16460 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
16461 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
16462 border:6px solid transparent;border-top-color:var(--text);}
16463 .info-chip:hover .info-chip-tip{display:block;}
16464 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
16465 .chip-slide.fading{filter:blur(5px);opacity:0;}
16466 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16467 .site-footer a{color:var(--muted);}
16468 .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;}
16469 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
16470 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
16471 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
16472 .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;}
16473 .lan-badge.local{background:var(--oxide-2);}
16474 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
16475 .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);}
16476 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
16477 .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;}
16478 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
16479 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
16480 .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;}
16481 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
16482 .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;}
16483 .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);}
16484 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
16485 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
16486 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
16487 .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;}
16488 @media (max-height: 1100px) {
16489 .page{padding-top:10px;}
16490 .hero{margin-bottom:10px;}
16491 .hero-logo{width:54px;height:60px;}
16492 .hero-logo-shadow{width:42px;}
16493 .hero-title{font-size:28px;}
16494 .hero-subtitle{font-size:13px;}
16495 .card-sections{gap:12px;margin-bottom:6px;}
16496 .card-section-grid-2,.card-section-grid-3{gap:10px;}
16497 .action-card{padding:8px 15px 8px;}
16498 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
16499 .action-card-icon svg{width:18px;height:18px;}
16500 .action-card-title{font-size:13px;}
16501 .action-card-desc{font-size:11px;margin-bottom:6px;}
16502 .action-card-cta{font-size:11px;}
16503 .ac-right-row{font-size:11px;}
16504 .divider{margin:14px 0;}
16505 .info-strip{gap:7px;margin-bottom:8px;}
16506 .info-chip{padding:7px 10px;}
16507 .info-chip-val{font-size:13px;}
16508 .info-chip-label{font-size:9px;}
16509 .site-footer{padding:8px 24px;font-size:12px;}
16510 .lan-local-hint{margin-top:8px;}
16511 }
16512 @media (max-height: 850px) {
16513 .page{padding-top:6px;}
16514 .hero{margin-bottom:6px;}
16515 .hero-logo{width:42px;height:46px;}
16516 .hero-title{font-size:22px;}
16517 .hero-subtitle{font-size:12px;}
16518 .card-sections{gap:10px;}
16519 .action-card-desc{margin-bottom:4px;}
16520 .divider{margin:8px 0;}
16521 .info-strip{margin-bottom:6px;}
16522 .lan-local-hint{margin-top:10px;}
16523 }
16524 </style>
16525</head>
16526<body>
16527 <div class="background-watermarks" aria-hidden="true">
16528 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16529 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16530 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16531 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16532 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16533 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16534 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16535 </div>
16536 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16537 <div class="top-nav">
16538 <div class="top-nav-inner">
16539 <a class="brand" href="/">
16540 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16541 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16542 </a>
16543 <div class="nav-right">
16544 <a class="nav-pill" href="/">Home</a>
16545 <div class="nav-dropdown">
16546 <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>
16547 <div class="nav-dropdown-menu">
16548 <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>
16549 </div>
16550 </div>
16551 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16552 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16553 <div class="nav-dropdown">
16554 <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>
16555 <div class="nav-dropdown-menu">
16556 <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>
16557 </div>
16558 </div>
16559 <div class="server-status-wrap" id="server-status-wrap">
16560 <div class="nav-pill server-online-pill" id="server-status-pill">
16561 <span class="status-dot" id="status-dot"></span>
16562 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
16563 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16564 </div>
16565 <div class="server-status-tip">
16566 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
16567 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16568 </div>
16569 </div>
16570 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16571 <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>
16572 </button>
16573 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16574 <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>
16575 <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>
16576 </button>
16577 </div>
16578 </div>
16579 </div>
16580
16581 <div class="page">
16582 <div class="hero">
16583 <div class="hero-logo-wrap" id="hero-logo-wrap">
16584 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
16585 </div>
16586 <div class="hero-logo-shadow"></div>
16587 <div class="hero-title-wrap">
16588 <div class="hero-title-aura" aria-hidden="true"></div>
16589 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
16590 </div>
16591 <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>
16592 </div>
16593
16594 <div class="card-sections">
16595
16596 <div>
16597 <div class="card-section-label">Analysis</div>
16598 <div class="card-section-grid-2">
16599 <a class="action-card scan card-split" href="/scan-setup">
16600 <div class="action-card-left">
16601 <div class="action-card-icon">
16602 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
16603 </div>
16604 <div class="action-card-title">Scan Project</div>
16605 <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>
16606 <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>
16607 </div>
16608 <div class="action-card-sep"></div>
16609 <div class="action-card-right">
16610 <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>
16611 <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>
16612 <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>
16613 <div class="ac-right-stat" id="acp-scan-stat"></div>
16614 </div>
16615 </a>
16616 <a class="action-card test-metrics card-split" href="/test-metrics">
16617 <div class="action-card-left">
16618 <div class="action-card-icon">
16619 <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>
16620 </div>
16621 <div class="action-card-title">Test Metrics</div>
16622 <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>
16623 <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>
16624 </div>
16625 <div class="action-card-sep"></div>
16626 <div class="action-card-right">
16627 <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>
16628 <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>
16629 <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>
16630 <div class="ac-right-stat" id="acp-test-stat"></div>
16631 </div>
16632 </a>
16633 </div>
16634 </div>
16635
16636 <div>
16637 <div class="card-section-label">Reports & Insights</div>
16638 <div class="card-section-grid-3">
16639 <a class="action-card view" href="/view-reports">
16640 <div class="action-card-icon">
16641 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
16642 </div>
16643 <div class="action-card-title">View Reports</div>
16644 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
16645 <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>
16646 </a>
16647 <a class="action-card compare" href="/compare-scans">
16648 <div class="action-card-icon">
16649 <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>
16650 </div>
16651 <div class="action-card-title">Compare Scans</div>
16652 <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>
16653 <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>
16654 </a>
16655 <a class="action-card trend" href="/trend-reports">
16656 <div class="action-card-icon">
16657 <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>
16658 </div>
16659 <div class="action-card-title">Trend Report</div>
16660 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
16661 <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>
16662 </a>
16663 </div>
16664 </div>
16665
16666 <div>
16667 <div class="card-section-label">Developer Tools</div>
16668 <div class="card-section-grid-2">
16669 <a class="action-card git-tools card-split" href="/git-browser">
16670 <div class="action-card-left">
16671 <div class="action-card-icon">
16672 <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>
16673 </div>
16674 <div class="action-card-title">Git Browser</div>
16675 <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>
16676 <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>
16677 </div>
16678 <div class="action-card-sep"></div>
16679 <div class="action-card-right">
16680 <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>
16681 <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>
16682 <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>
16683 </div>
16684 </a>
16685 <a class="action-card automation card-split" href="/integrations">
16686 <div class="action-card-left">
16687 <div class="action-card-icon">
16688 <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>
16689 </div>
16690 <div class="action-card-title">Integrations</div>
16691 <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>
16692 <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>
16693 </div>
16694 <div class="action-card-sep"></div>
16695 <div class="action-card-right">
16696 <div class="ac-badges-grid">
16697 <span class="ac-badge github" id="acp-gh">GitHub</span>
16698 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
16699 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
16700 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
16701 </div>
16702 <div class="ac-right-stat" id="acp-int-stat"></div>
16703 </div>
16704 </a>
16705 </div>
16706 </div>
16707
16708 </div>
16709
16710 {% if server_mode %}
16711 <div class="lan-card server">
16712 <div class="lan-card-header">
16713 <span class="lan-badge">LAN server</span>
16714 Accessible on your network
16715 </div>
16716 {% if let Some(ip) = lan_ip %}
16717 <div class="lan-url-row">
16718 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
16719 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
16720 <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>
16721 Copy URL
16722 </button>
16723 </div>
16724 <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>
16725 {% if has_api_key %}
16726 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
16727 {% endif %}
16728 {% else %}
16729 <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>
16730 {% endif %}
16731 </div>
16732 {% endif %}
16733
16734 <div class="divider"></div>
16735
16736 <div class="info-strip">
16737 <div class="info-chip">
16738 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
16739 <div class="chip-slide">
16740 <div class="info-chip-val">41</div>
16741 <div class="info-chip-label">Languages</div>
16742 </div>
16743 </div>
16744 <div class="info-chip">
16745 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
16746 <div class="chip-slide">
16747 <div class="info-chip-val">100%</div>
16748 <div class="info-chip-label">Self-contained</div>
16749 </div>
16750 </div>
16751 <div class="info-chip">
16752 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
16753 <div class="chip-slide">
16754 <div class="info-chip-val">HTML+PDF</div>
16755 <div class="info-chip-label">Exportable reports</div>
16756 </div>
16757 </div>
16758 <div class="info-chip">
16759 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
16760 <div class="chip-slide">
16761 <div class="info-chip-val">Webhook</div>
16762 <div class="info-chip-label">3 platforms</div>
16763 </div>
16764 </div>
16765 <div class="info-chip">
16766 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
16767 <div class="chip-slide">
16768 <div class="info-chip-val">IEEE</div>
16769 <div class="info-chip-label">1045-1992</div>
16770 </div>
16771 </div>
16772 </div>
16773
16774 {% if lan_ip.is_none() %}
16775 <div class="lan-local-hint">
16776 <strong>Want teammates on the same network to access this?</strong><br>
16777 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
16778 </div>
16779 {% endif %}
16780 </div>
16781
16782 <footer class="site-footer">
16783 local code analysis - metrics, history and reports
16784 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
16785 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16786 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16787 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16788 · <a href="/api-docs" rel="noopener">REST API</a>
16789 </footer>
16790
16791 <script nonce="{{ csp_nonce }}">
16792 (function () {
16793 var storageKey = 'oxide-sloc-theme';
16794 var body = document.body;
16795 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16796 var toggle = document.getElementById('theme-toggle');
16797 if (toggle) toggle.addEventListener('click', function () {
16798 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16799 body.classList.toggle('dark-theme', next === 'dark');
16800 try { localStorage.setItem(storageKey, next); } catch(e) {}
16801 });
16802 var copyBtn = document.getElementById('lan-copy-btn');
16803 if (copyBtn) copyBtn.addEventListener('click', function() {
16804 var btn = this;
16805 var el = document.getElementById('lan-url-val');
16806 if (!el) return;
16807 var url = el.textContent.trim();
16808 if (navigator.clipboard) {
16809 navigator.clipboard.writeText(url).then(function() {
16810 var orig = btn.innerHTML;
16811 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!';
16812 setTimeout(function() { btn.innerHTML = orig; }, 1800);
16813 });
16814 }
16815 });
16816 (function randomizeWatermarks() {
16817 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16818 if (!wms.length) return;
16819 var placed = [];
16820 function tooClose(top, left) {
16821 for (var i = 0; i < placed.length; i++) {
16822 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
16823 if (dt < 16 && dl < 12) return true;
16824 }
16825 return false;
16826 }
16827 function pick(leftBand) {
16828 for (var attempt = 0; attempt < 50; attempt++) {
16829 var top = Math.random() * 88 + 2;
16830 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16831 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16832 }
16833 var top = Math.random() * 88 + 2;
16834 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16835 placed.push([top, left]); return [top, left];
16836 }
16837 var half = Math.floor(wms.length / 2);
16838 wms.forEach(function (img, i) {
16839 var pos = pick(i < half);
16840 var size = Math.floor(Math.random() * 100 + 120);
16841 var rot = (Math.random() * 360).toFixed(1);
16842 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
16843 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;
16844 });
16845 })();
16846
16847 (function spawnCodeParticles() {
16848 var container = document.getElementById('code-particles');
16849 if (!container) return;
16850 var snippets = [
16851 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
16852 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
16853 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
16854 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
16855 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
16856 ];
16857 var count = 38;
16858 for (var i = 0; i < count; i++) {
16859 (function(idx) {
16860 var el = document.createElement('span');
16861 el.className = 'code-particle';
16862 var text = snippets[idx % snippets.length];
16863 el.textContent = text;
16864 var left = Math.random() * 94 + 2;
16865 var top = Math.random() * 88 + 6;
16866 var dur = (Math.random() * 10 + 9).toFixed(1);
16867 var delay = (Math.random() * 18).toFixed(1);
16868 var rot = (Math.random() * 26 - 13).toFixed(1);
16869 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16870 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
16871 + '--rot:' + rot + 'deg;--op:' + op + ';'
16872 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
16873 container.appendChild(el);
16874 })(i);
16875 }
16876 })();
16877 (function heroAnimations() {
16878 var sub = document.getElementById('hero-subtitle');
16879 if (sub) {
16880 var full = sub.textContent.trim();
16881 sub.textContent = '';
16882 sub.style.opacity = '1';
16883 var cursor = document.createElement('span');
16884 cursor.className = 'hero-cursor';
16885 sub.appendChild(cursor);
16886 var i = 0;
16887 setTimeout(function() {
16888 var iv = setInterval(function() {
16889 if (i < full.length) {
16890 sub.insertBefore(document.createTextNode(full[i]), cursor);
16891 i++;
16892 } else {
16893 clearInterval(iv);
16894 setTimeout(function() {
16895 cursor.style.transition = 'opacity 1s ease';
16896 cursor.style.opacity = '0';
16897 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
16898 }, 2400);
16899 }
16900 }, 11);
16901 }, 374);
16902 }
16903 })();
16904 (function logoBob() {
16905 var logo = document.querySelector('.hero-logo');
16906 var shadow = document.querySelector('.hero-logo-shadow');
16907 if (!logo) return;
16908 var cycleStart = null, cycleDur = 3600;
16909 var peakY = -14, peakScale = 1.07, peakRot = 0;
16910 function newCycle() {
16911 cycleDur = 3000 + Math.random() * 1840;
16912 peakY = -(9 + Math.random() * 13.8);
16913 peakScale = 1.04 + Math.random() * 0.081;
16914 peakRot = (Math.random() * 11.5 - 5.75);
16915 }
16916 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
16917 newCycle();
16918 function frame(ts) {
16919 if (cycleStart === null) cycleStart = ts;
16920 var t = (ts - cycleStart) / cycleDur;
16921 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
16922 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
16923 var y = peakY * phase;
16924 var sc = 1 + (peakScale - 1) * phase;
16925 var rot = peakRot * Math.sin(Math.PI * phase);
16926 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
16927 if (shadow) {
16928 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
16929 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
16930 }
16931 requestAnimationFrame(frame);
16932 }
16933 requestAnimationFrame(frame);
16934 })();
16935 (function mouseEffects() {
16936 var heroTitle = document.getElementById('hero-title');
16937 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
16938 function tick() {
16939 raf = null;
16940 if (heroTitle) {
16941 var r = heroTitle.getBoundingClientRect();
16942 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
16943 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
16944 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
16945 }
16946 }
16947 document.addEventListener('mousemove', function(e) {
16948 mx = e.clientX; my = e.clientY;
16949 if (!raf) raf = requestAnimationFrame(tick);
16950 });
16951 document.addEventListener('mouseleave', function() {
16952 if (heroTitle) {
16953 heroTitle.style.transition = 'transform 0.5s ease';
16954 heroTitle.style.transform = '';
16955 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
16956 }
16957 });
16958 document.querySelectorAll('.action-card').forEach(function(card) {
16959 card.addEventListener('mousemove', function(e) {
16960 var rect = card.getBoundingClientRect();
16961 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
16962 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
16963 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
16964 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
16965 });
16966 card.addEventListener('mouseleave', function() {
16967 card.style.transition = '';
16968 card.style.transform = '';
16969 });
16970 });
16971 })();
16972 (function chipSlideshow() {
16973 var slides = [
16974 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
16975 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
16976 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
16977 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
16978 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
16979 ];
16980 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
16981 var indices = [0,0,0,0,0];
16982 var paused = [false,false,false,false,false];
16983 chips.forEach(function(chip, i) {
16984 chip.addEventListener('mouseenter', function() { paused[i] = true; });
16985 chip.addEventListener('mouseleave', function() { paused[i] = false; });
16986 });
16987 function advance(i) {
16988 if (paused[i]) return;
16989 var chip = chips[i];
16990 var inner = chip.querySelector('.chip-slide');
16991 if (!inner) return;
16992 inner.classList.add('fading');
16993 setTimeout(function() {
16994 indices[i] = (indices[i] + 1) % slides[i].length;
16995 var s = slides[i][indices[i]];
16996 chip.querySelector('.info-chip-val').textContent = s.v;
16997 chip.querySelector('.info-chip-label').textContent = s.l;
16998 inner.classList.remove('fading');
16999 }, 720);
17000 }
17001 setInterval(function() {
17002 chips.forEach(function(chip, i) { advance(i); });
17003 }, 6000);
17004 })();
17005 (function cardLiveData() {
17006 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
17007 var el = document.getElementById('acp-scan-stat');
17008 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
17009 }).catch(function(){});
17010 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
17011 var el = document.getElementById('acp-test-stat');
17012 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
17013 }).catch(function(){});
17014 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
17015 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
17016 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
17017 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
17018 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
17019 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
17020 var stat = document.getElementById('acp-int-stat');
17021 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
17022 }).catch(function(){});
17023 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
17024 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
17025 }).catch(function(){});
17026 })();
17027 })();
17028 </script>
17029 <script nonce="{{ csp_nonce }}">
17030 (function(){
17031 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'}];
17032 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);});}
17033 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17034 function init(){
17035 var btn=document.getElementById('settings-btn');if(!btn)return;
17036 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17037 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>';
17038 document.body.appendChild(m);
17039 var g=document.getElementById('scheme-grid');
17040 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);});
17041 var cl=document.getElementById('settings-close');
17042 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);
17043 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');});
17044 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17045 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17046 }
17047 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17048 }());
17049 </script>
17050 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
17051</body>
17052</html>
17053"##,
17054 ext = "html"
17055)]
17056struct SplashTemplate {
17057 csp_nonce: String,
17058 server_mode: bool,
17059 lan_ip: Option<String>,
17060 port: u16,
17061 version: &'static str,
17062 has_api_key: bool,
17063}
17064
17065#[derive(Template)]
17068#[template(
17069 source = r##"
17070<!doctype html>
17071<html lang="en">
17072<head>
17073 <meta charset="utf-8">
17074 <meta name="viewport" content="width=device-width, initial-scale=1">
17075 <title>OxideSLOC — Start a Scan</title>
17076 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17077 <style nonce="{{ csp_nonce }}">
17078 :root {
17079 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
17080 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17081 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
17082 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17083 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
17084 }
17085 body.dark-theme {
17086 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
17087 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
17088 }
17089 *{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;}
17090 .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);}
17091 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17092 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
17093 .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));}
17094 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
17095 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17096 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17097 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17098 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17099 @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; } }
17100 .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;}
17101 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17102 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17103 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17104 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17105 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17106 .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;}
17107 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17108 .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);}
17109 .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;}
17110 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17111 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17112 .settings-modal-body{padding:14px 16px 16px;}
17113 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17114 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17115 .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;}
17116 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17117 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17118 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17119 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17120 .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;}
17121 .tz-select:focus{border-color:var(--oxide);}
17122 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
17123 .page-header{text-align:center;margin-bottom:16px;}
17124 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
17125 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
17126 /* Cards */
17127 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
17128 .option-card-wrap{position:relative;}
17129 .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;}
17130 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
17131 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
17132 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
17133 .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;}
17134 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
17135 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
17136 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
17137 .card-top-row{display:flex;align-items:center;gap:20px;}
17138 /* Two-column layout inside each card */
17139 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
17140 .card-left{display:flex;align-items:flex-start;min-width:0;}
17141 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
17142 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
17143 .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);}
17144 .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);}
17145 .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);}
17146 .card-text{min-width:0;}
17147 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
17148 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
17149 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
17150 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
17151 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
17152 /* Right CTA column */
17153 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
17154 .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;}
17155 /* Re-scan count badge */
17156 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
17157 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
17158 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
17159 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
17160 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
17161 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
17162 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
17163 body.dark-theme .btn-secondary{color:var(--oxide);}
17164 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
17165 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
17166 /* File input overlay — must be full-width so it aligns with other card-right buttons */
17167 .file-input-wrap{position:relative;width:100%;}
17168 .file-input-wrap .btn{width:100%;}
17169 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
17170 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17171 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17172 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17173 .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;}
17174 @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));}}
17175 /* Recent list (card 3 — full-width section below header) */
17176 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
17177 .recent-list{display:flex;flex-direction:column;gap:8px;}
17178 .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;}
17179 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
17180 .recent-item-info{flex:1;min-width:0;}
17181 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
17182 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
17183 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
17184 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
17185 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17186 .site-footer a{color:var(--muted);}
17187 @media(max-width:680px){
17188 .card-body{grid-template-columns:1fr;}
17189 .card-right{flex-direction:row;flex-wrap:wrap;}
17190 .btn{flex:1;}
17191 }
17192 .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;}
17193 .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;}
17194 .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;}
17195 </style>
17196</head>
17197<body>
17198 <div class="background-watermarks" aria-hidden="true">
17199 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17200 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17201 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17202 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17203 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17204 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17205 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17206 </div>
17207 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17208 <div class="top-nav">
17209 <div class="top-nav-inner">
17210 <a class="brand" href="/">
17211 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17212 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
17213 </a>
17214 <div class="nav-right">
17215 <a class="nav-pill" href="/">Home</a>
17216 <div class="nav-dropdown">
17217 <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>
17218 <div class="nav-dropdown-menu">
17219 <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>
17220 </div>
17221 </div>
17222 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17223 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17224 <div class="nav-dropdown">
17225 <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>
17226 <div class="nav-dropdown-menu">
17227 <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>
17228 </div>
17229 </div>
17230 <div class="server-status-wrap" id="server-status-wrap">
17231 <div class="nav-pill server-online-pill" id="server-status-pill">
17232 <span class="status-dot" id="status-dot"></span>
17233 <span id="server-status-label">Server</span>
17234 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17235 </div>
17236 <div class="server-status-tip">
17237 OxideSLOC is running — accessible on your network.
17238 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17239 </div>
17240 </div>
17241 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17242 <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>
17243 </button>
17244 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17245 <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>
17246 <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>
17247 </button>
17248 </div>
17249 </div>
17250 </div>
17251
17252 <div class="page">
17253 <div class="page-header">
17254 <h1>How would you like to scan?</h1>
17255 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
17256 </div>
17257
17258 <div class="option-grid">
17259
17260 <!-- Option 1: New scan -->
17261 <div class="option-card-wrap">
17262 <div class="option-card">
17263 <div class="option-icon new-scan">
17264 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
17265 </div>
17266 <div class="card-body">
17267 <div class="card-left">
17268 <div class="card-text">
17269 <div class="option-title">Start a new scan</div>
17270 <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>
17271 <ul class="feature-list">
17272 <li>Live project scope preview before you run</li>
17273 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
17274 <li>HTML, PDF, and JSON output — your choice</li>
17275 </ul>
17276 </div>
17277 </div>
17278 <div class="card-right">
17279 <a class="btn btn-primary" href="/scan">
17280 Configure & scan
17281 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
17282 </a>
17283 <p class="card-tip">Full 4-step setup · all options</p>
17284 </div>
17285 </div>
17286 </div>
17287 </div>
17288
17289 <!-- Option 2: Load from config file -->
17290 <div class="option-card-wrap">
17291 <div class="option-card">
17292 <div class="option-icon load-config">
17293 <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>
17294 </div>
17295 <div class="card-body">
17296 <div class="card-left">
17297 <div class="card-text">
17298 <div class="option-title">Load a saved config</div>
17299 <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>
17300 <ul class="feature-list">
17301 <li>All 15 settings restored from the file</li>
17302 <li>Fully editable — change path or output dir</li>
17303 <li>Works with any scan-config.json</li>
17304 </ul>
17305 </div>
17306 </div>
17307 <div class="card-right">
17308 <div class="file-input-wrap">
17309 <button class="btn btn-secondary" id="load-config-btn" type="button">
17310 <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>
17311 Choose config file
17312 </button>
17313 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
17314 </div>
17315 <p class="card-tip" id="config-file-name">Exported after every scan</p>
17316 </div>
17317 </div>
17318 </div>
17319 </div>
17320
17321 <!-- Option 3: Re-scan recent project -->
17322 <div class="option-card-wrap">
17323 <div class="option-card" id="recent-card">
17324 <div class="card-top-row">
17325 <div class="option-icon rescan">
17326 <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>
17327 </div>
17328 <div class="card-body">
17329 <div class="card-left">
17330 <div class="card-text">
17331 <div class="option-title">Re-scan a recent project</div>
17332 <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>
17333 <ul class="feature-list">
17334 <li>All 15+ settings restored from the saved config</li>
17335 <li>Path and output dir are editable before running</li>
17336 <li>Only scans with a saved config appear here</li>
17337 </ul>
17338 </div>
17339 </div>
17340 <div class="card-right">
17341 <div class="rescan-count-box">
17342 <div class="rescan-count-num" id="rescan-count-num">—</div>
17343 <div class="rescan-count-label">saved configs</div>
17344 </div>
17345 <a class="btn btn-secondary" href="/view-reports">
17346 <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>
17347 View all runs
17348 </a>
17349 <p class="card-tip">Opens run history</p>
17350 </div>
17351 </div>
17352 </div>
17353 <div class="section-divider"></div>
17354 <div class="recent-list" id="recent-list">
17355 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
17356 </div>
17357 </div>
17358 </div>
17359
17360 </div>
17361 </div>
17362
17363 <footer class="site-footer">
17364 local code analysis - metrics, history and reports
17365 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17366 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17367 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17368 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17369 · <a href="/api-docs" rel="noopener">REST API</a>
17370 </footer>
17371
17372 <script nonce="{{ csp_nonce }}">
17373 (function () {
17374 var storageKey = 'oxide-sloc-theme';
17375 var body = document.body;
17376 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17377 var toggle = document.getElementById('theme-toggle');
17378 if (toggle) toggle.addEventListener('click', function () {
17379 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17380 body.classList.toggle('dark-theme', next === 'dark');
17381 try { localStorage.setItem(storageKey, next); } catch(e) {}
17382 });
17383
17384 (function randomizeWatermarks() {
17385 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17386 if (!wms.length) return;
17387 var placed = [];
17388 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; }
17389 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]; }
17390 var half = Math.floor(wms.length / 2);
17391 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; });
17392 })();
17393 (function spawnCodeParticles() {
17394 var container = document.getElementById('code-particles');
17395 if (!container) return;
17396 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'];
17397 var count = 38;
17398 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); }
17399 })();
17400 // Recent scans data injected from server
17401 var recentScans = {{ recent_scans_json|safe }};
17402
17403 function configToParams(cfg) {
17404 var p = new URLSearchParams();
17405 p.set('prefilled', '1');
17406 if (cfg.path) p.set('path', cfg.path);
17407 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
17408 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
17409 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
17410 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
17411 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
17412 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
17413 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
17414 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
17415 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
17416 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
17417 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
17418 if (cfg.report_title) p.set('report_title', cfg.report_title);
17419 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
17420 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
17421 return p;
17422 }
17423
17424 // Build recent scan list (capped at 3 visible entries)
17425 var list = document.getElementById('recent-list');
17426 var noNote = document.getElementById('no-recent-note');
17427 var hasAny = false;
17428 var MAX_RECENT = 3;
17429 if (Array.isArray(recentScans)) {
17430 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
17431 var shown = 0;
17432 validEntries.forEach(function (entry) {
17433 if (shown >= MAX_RECENT) return;
17434 shown++;
17435 hasAny = true;
17436 var item = document.createElement('div');
17437 item.className = 'recent-item';
17438 item.title = 'Restore all settings and open wizard';
17439 item.innerHTML =
17440 '<div class="recent-item-info">' +
17441 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
17442 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
17443 '</div>' +
17444 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
17445 item.addEventListener('click', function () {
17446 var params = configToParams(entry.config);
17447 window.location.href = '/scan?' + params.toString();
17448 });
17449 list.appendChild(item);
17450 });
17451 if (validEntries.length > MAX_RECENT) {
17452 var moreEl = document.createElement('div');
17453 moreEl.className = 'recent-more-link';
17454 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
17455 list.appendChild(moreEl);
17456 }
17457 }
17458 if (hasAny && noNote) noNote.style.display = 'none';
17459 // Update count badge
17460 var countEl = document.getElementById('rescan-count-num');
17461 if (countEl) {
17462 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
17463 countEl.textContent = total > 0 ? total : '0';
17464 }
17465
17466 // Config file loader
17467 var fileInput = document.getElementById('config-file-input');
17468 var fileName = document.getElementById('config-file-name');
17469 var loadBtn = document.getElementById('load-config-btn');
17470 // Wire the visible button to open the hidden file picker.
17471 if (loadBtn && fileInput) {
17472 loadBtn.addEventListener('click', function () { fileInput.click(); });
17473 }
17474 if (fileInput) {
17475 fileInput.addEventListener('change', function () {
17476 var file = fileInput.files && fileInput.files[0];
17477 if (!file) return;
17478 if (fileName) fileName.textContent = '✓ ' + file.name;
17479 var reader = new FileReader();
17480 reader.onload = function (e) {
17481 try {
17482 var cfg = JSON.parse(e.target.result);
17483 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
17484 var params = configToParams(cfg);
17485 window.location.href = '/scan?' + params.toString();
17486 } catch (err) {
17487 alert('Could not parse config file: ' + err.message);
17488 }
17489 };
17490 reader.readAsText(file);
17491 });
17492 }
17493
17494 function escHtml(s) {
17495 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
17496 }
17497 })();
17498 </script>
17499 <script nonce="{{ csp_nonce }}">
17500 (function(){
17501 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'}];
17502 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);});}
17503 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17504 function init(){
17505 var btn=document.getElementById('settings-btn');if(!btn)return;
17506 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17507 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>';
17508 document.body.appendChild(m);
17509 var g=document.getElementById('scheme-grid');
17510 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);});
17511 var cl=document.getElementById('settings-close');
17512 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);
17513 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');});
17514 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17515 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17516 }
17517 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17518 }());
17519 </script>
17520 <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=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>
17521</body>
17522</html>
17523"##,
17524 ext = "html"
17525)]
17526struct ScanSetupTemplate {
17527 version: &'static str,
17528 recent_scans_json: String,
17529 csp_nonce: String,
17530}
17531
17532#[derive(Template)]
17533#[template(
17534 source = r##"
17535<!doctype html>
17536<html lang="en">
17537<head>
17538 <meta charset="utf-8">
17539 <meta name="viewport" content="width=device-width, initial-scale=1">
17540 <title>OxideSLOC | {{ report_title }} | Report</title>
17541 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17542 <style nonce="{{ csp_nonce }}">
17543 :root {
17544 --radius: 18px;
17545 --bg: #f5efe8;
17546 --surface: rgba(255,255,255,0.82);
17547 --surface-2: #fbf7f2;
17548 --surface-3: #efe6dc;
17549 --line: #e6d0bf;
17550 --line-strong: #dcb89f;
17551 --text: #43342d;
17552 --muted: #7b675b;
17553 --muted-2: #a08777;
17554 --nav: #b85d33;
17555 --nav-2: #7a371b;
17556 --accent: #6f9bff;
17557 --accent-2: #4a78ee;
17558 --oxide: #d37a4c;
17559 --oxide-2: #b35428;
17560 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
17561 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
17562 --success-bg: #e8f5ed;
17563 --success-text: #1a8f47;
17564 --info-bg: #eef3ff;
17565 --info-text: #4467d8;
17566 }
17567
17568 body.dark-theme {
17569 --bg: #1b1511;
17570 --surface: #261c17;
17571 --surface-2: #2d221d;
17572 --surface-3: #372922;
17573 --line: #524238;
17574 --line-strong: #6c5649;
17575 --text: #f5ece6;
17576 --muted: #c7b7aa;
17577 --muted-2: #aa9485;
17578 --nav: #b85d33;
17579 --nav-2: #7a371b;
17580 --accent: #6f9bff;
17581 --accent-2: #4a78ee;
17582 --oxide: #d37a4c;
17583 --oxide-2: #b35428;
17584 --shadow: 0 18px 42px rgba(0,0,0,0.28);
17585 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
17586 --success-bg: #163927;
17587 --success-text: #8fe2a8;
17588 --info-bg: #1c2847;
17589 --info-text: #a9c1ff;
17590 }
17591
17592 * { box-sizing: border-box; }
17593 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); }
17594 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17595 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17596 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17597 .top-nav, .page { position: relative; z-index: 2; }
17598 .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); }
17599 .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; }
17600 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17601 .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)); }
17602 .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; }
17603 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17604 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17605 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17606 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17607 .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; }
17608 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17609 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17610 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17611 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17612 @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; } }
17613 .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; }
17614 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17615 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17616 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17617 .theme-toggle .icon-sun { display:none; }
17618 body.dark-theme .theme-toggle .icon-sun { display:block; }
17619 body.dark-theme .theme-toggle .icon-moon { display:none; }
17620 .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;}
17621 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17622 .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);}
17623 .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;}
17624 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17625 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17626 .settings-modal-body{padding:14px 16px 16px;}
17627 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17628 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17629 .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;}
17630 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17631 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17632 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17633 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17634 .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;}
17635 .tz-select:focus{border-color:var(--oxide);}
17636 .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; }
17637 .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;}
17638 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
17639 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
17640 .hero, .panel { padding: 22px; }
17641 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
17642 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17643 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
17644 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
17645 .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; }
17646 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
17647 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
17648 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
17649 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
17650 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
17651 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
17652 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; min-width:92px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; }
17653 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
17654 .delta-card-val { font-size:16px; font-weight:800; }
17655 .delta-card-val.pos { color:#1e7e34; }
17656 .delta-card-val.neg { color:var(--neg); }
17657 .delta-card-val.mod { color:#b35428; }
17658 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
17659 .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .2s ease; z-index:200; }
17660 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17661 .delta-card-inline:hover .delta-card-tip { opacity:1; }
17662 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
17663 .compare-ts { font-size:13px; color:var(--muted); }
17664 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
17665 .compare-arrow { color: var(--muted); }
17666 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
17667 .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; }
17668 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
17669 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
17670 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
17671 .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; }
17672 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
17673 .run-mgmt-card .action-buttons { justify-content:center; }
17674 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
17675 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
17676 .button, .copy-button {
17677 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;
17678 }
17679 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
17680 @keyframes spin { to { transform: rotate(360deg); } }
17681 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
17682 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
17683 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
17684 .path-item strong { display: block; margin-bottom: 6px; }
17685 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
17686 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
17687 .path-subitem { flex: 1; }
17688 .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); }
17689 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); }
17690 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
17691 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
17692 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
17693 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
17694 th { color: var(--muted); font-weight: 700; }
17695 tr:last-child td { border-bottom: none; }
17696 #subm-tbl col:nth-child(1){width:15%;}
17697 #subm-tbl col:nth-child(2){width:31%;}
17698 #subm-tbl col:nth-child(3){width:9%;}
17699 #subm-tbl col:nth-child(4){width:9%;}
17700 #subm-tbl col:nth-child(5){width:9%;}
17701 #subm-tbl col:nth-child(6){width:9%;}
17702 #subm-tbl col:nth-child(7){width:9%;}
17703 #subm-tbl col:nth-child(8){width:9%;}
17704 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
17705 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
17706 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
17707 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
17708 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
17709 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
17710 .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; }
17711 .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; }
17712 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
17713 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
17714 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
17715 .muted { color: var(--muted); }
17716 /* Run-ID chip row (mirrors HTML report) */
17717 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
17718 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
17719 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
17720 .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; }
17721 .run-id-chip[data-copy] { cursor:pointer; }
17722 a.run-id-chip { text-decoration:none; cursor:pointer; }
17723 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
17724 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
17725 .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; }
17726 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
17727 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17728 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
17729 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
17730 a.commit-link-value { color:inherit; text-decoration:none; }
17731 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
17732 .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity 0.18s ease; z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
17733 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17734 .run-id-chip:hover .chip-tooltip { opacity:1; }
17735 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
17736 .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; }
17737 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
17738 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
17739 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
17740 /* Meta chips row */
17741 .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%; }
17742 .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; }
17743 .meta-chip:last-child { border-right:none; }
17744 .meta-chip b { color:var(--text); font-weight:700; }
17745 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17746 .site-footer a{color:var(--muted);}
17747 .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; }
17748 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
17749 .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; }
17750 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
17751 /* Stat chips (matches HTML report) */
17752 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
17753 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
17754 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
17755 .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; overflow:visible; }
17756 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
17757 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
17758 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
17759 .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; }
17760 .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:7px 12px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .2s ease; z-index:200; }
17761 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17762 .stat-chip:hover .stat-chip-tip { opacity:1; }
17763 /* Submodule panel */
17764 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
17765 /* Metrics tables stack */
17766 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
17767 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
17768 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
17769 .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)); }
17770 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
17771 /* Metrics table */
17772 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
17773 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
17774 .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; }
17775 .metrics-table thead th:not(:first-child) { text-align: right; }
17776 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
17777 .metrics-table tbody tr:last-child td { border-bottom: none; }
17778 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
17779 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
17780 .metrics-table tbody tr:hover td { background: var(--surface-2); }
17781 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
17782 .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; }
17783 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
17784 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
17785 .mt-val-pos { color: var(--pos); font-weight: 700; }
17786 .mt-val-neg { color: var(--neg); font-weight: 700; }
17787 .mt-val-zero { color: var(--muted); }
17788 .mt-val-mod { color: var(--oxide-2); }
17789 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
17790 @media (max-width: 1180px) {
17791 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
17792 .nav-project-slot, .nav-status { justify-content:flex-start; }
17793 .hero-top { flex-direction: column; }
17794 .run-mgmt-strip { flex-direction: column; }
17795 }
17796 .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;}
17797 @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));}}
17798 .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;}
17799 /* ── Result-page chart controls ─────────────────────────────────────────── */
17800 .r-chart-section{margin-bottom:24px;}
17801 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
17802 .section-pair > .panel{flex-shrink:0;}
17803 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
17804 .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;}
17805 .r-chart-select:focus{border-color:var(--accent);}
17806 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
17807 .r-chart-container svg{display:block;width:100%;height:auto;}
17808 .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;}
17809 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
17810 .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;}
17811 .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);}
17812 .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;}
17813 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
17814 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
17815 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
17816 .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;}
17817 .r-chart-modal-close:hover{opacity:.7;}
17818 body.dark-theme .r-chart-modal{background:var(--surface);}
17819 .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;}
17820 .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);}
17821 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
17822 .lang-bar-row:hover{transform:translateY(-2px);}
17823 .lang-bar-row .rchit:hover{filter:none;transform:none;}
17824 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
17825 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
17826 .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;}
17827 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
17828 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
17829 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
17830 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
17831 #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;}
17832 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
17833 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
17834 .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;}
17835 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
17836 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
17837 .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;}
17838 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
17839 .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;}
17840 .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;}
17841 body.has-report-banner .top-nav{top:27px;}
17842 body.has-report-banner{padding-bottom:27px;}
17843 </style>
17844</head>
17845<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
17846 <div class="background-watermarks" aria-hidden="true">
17847 <img src="/images/logo/logo-text.png" alt="" />
17848 <img src="/images/logo/logo-text.png" alt="" />
17849 <img src="/images/logo/logo-text.png" alt="" />
17850 <img src="/images/logo/logo-text.png" alt="" />
17851 <img src="/images/logo/logo-text.png" alt="" />
17852 <img src="/images/logo/logo-text.png" alt="" />
17853 <img src="/images/logo/logo-text.png" alt="" />
17854 <img src="/images/logo/logo-text.png" alt="" />
17855 <img src="/images/logo/logo-text.png" alt="" />
17856 <img src="/images/logo/logo-text.png" alt="" />
17857 <img src="/images/logo/logo-text.png" alt="" />
17858 <img src="/images/logo/logo-text.png" alt="" />
17859 <img src="/images/logo/logo-text.png" alt="" />
17860 <img src="/images/logo/logo-text.png" alt="" />
17861 </div>
17862 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17863 {% if let Some(banner) = report_header_footer %}
17864 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
17865 {% endif %}
17866 <div class="top-nav">
17867 <div class="top-nav-inner">
17868 <a class="brand" href="/">
17869 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17870 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
17871 </a>
17872 <div class="nav-project-slot">
17873 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
17874 </div>
17875 <div class="nav-status">
17876 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
17877 <div class="nav-dropdown">
17878 <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>
17879 <div class="nav-dropdown-menu">
17880 <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>
17881 </div>
17882 </div>
17883 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
17884 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17885 <div class="nav-dropdown">
17886 <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>
17887 <div class="nav-dropdown-menu">
17888 <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>
17889 </div>
17890 </div>
17891 <div class="server-status-wrap" id="server-status-wrap">
17892 <div class="nav-pill server-online-pill" id="server-status-pill">
17893 <span class="status-dot" id="status-dot"></span>
17894 <span id="server-status-label">Server</span>
17895 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17896 </div>
17897 <div class="server-status-tip">
17898 OxideSLOC is running — accessible on your network.
17899 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17900 </div>
17901 </div>
17902 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17903 <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>
17904 </button>
17905 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17906 <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>
17907 <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>
17908 </button>
17909 </div>
17910 </div>
17911 </div>
17912
17913 <div class="page">
17914 <section class="hero">
17915 <div class="hero-top">
17916 <div>
17917 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
17918 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
17919 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
17920 <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>
17921 </div>
17922 </div>
17923 <div class="hero-quick-actions">
17924 {% if server_mode %}
17925 <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>
17926 {% else %}
17927 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
17928 {% endif %}
17929 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
17930 {% if !server_mode %}
17931 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
17932 {% endif %}
17933 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
17934 <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>
17935 </div>
17936 </div>
17937
17938 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
17939 <div class="run-id-row">
17940 <span class="run-id-chip" data-copy="{{ run_id }}">
17941 <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>
17942 <span class="run-id-chip-value">{{ run_id }}</span>
17943 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
17944 </span>
17945 {% match git_commit_long %}
17946 {% when Some with (long_sha) %}
17947 {% match git_commit_url %}
17948 {% when Some with (commit_url) %}
17949 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
17950 <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>
17951 <span class="run-id-chip-value">{{ long_sha }}</span>
17952 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
17953 </a>
17954 {% when None %}
17955 <span class="run-id-chip" data-copy="{{ long_sha }}">
17956 <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>
17957 <span class="run-id-chip-value">{{ long_sha }}</span>
17958 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
17959 </span>
17960 {% endmatch %}
17961 {% when None %}
17962 <span class="run-id-chip muted-chip">
17963 <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>
17964 <span class="run-id-chip-value">Not detected</span>
17965 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
17966 </span>
17967 {% endmatch %}
17968 {% match git_branch %}
17969 {% when Some with (branch) %}
17970 {% match git_branch_url %}
17971 {% when Some with (branch_url) %}
17972 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
17973 <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>
17974 <span class="run-id-chip-value">{{ branch }}</span>
17975 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
17976 </a>
17977 {% when None %}
17978 <span class="run-id-chip">
17979 <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>
17980 <span class="run-id-chip-value">{{ branch }}</span>
17981 <span class="chip-tooltip">Git branch active at scan time</span>
17982 </span>
17983 {% endmatch %}
17984 {% when None %}
17985 <span class="run-id-chip muted-chip">
17986 <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>
17987 <span class="run-id-chip-value">Not detected</span>
17988 <span class="chip-tooltip">No Git branch was found for this scan</span>
17989 </span>
17990 {% endmatch %}
17991 {% match git_author %}
17992 {% when Some with (author) %}
17993 <span class="run-id-chip" data-author="{{ author }}">
17994 <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>
17995 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
17996 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
17997 </span>
17998 {% when None %}
17999 <span class="run-id-chip muted-chip">
18000 <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>
18001 <span class="run-id-chip-value">Not detected</span>
18002 <span class="chip-tooltip">No commit author was found for this scan</span>
18003 </span>
18004 {% endmatch %}
18005 </div>
18006
18007 <!-- Scan metadata row -->
18008 <div class="meta">
18009 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
18010 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
18011 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
18012 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
18013 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
18014 </div>
18015
18016 <!-- 12 summary stat chips -->
18017 <div class="summary-strip">
18018 <div class="stat-chip" data-raw="{{ physical_lines }}">
18019 <div class="stat-chip-label">Physical lines</div>
18020 <div class="stat-chip-val">{{ physical_lines }}</div>
18021 <div class="stat-chip-exact"></div>
18022 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
18023 </div>
18024 <div class="stat-chip" data-raw="{{ code_lines }}">
18025 <div class="stat-chip-label">Code</div>
18026 <div class="stat-chip-val">{{ code_lines }}</div>
18027 <div class="stat-chip-exact"></div>
18028 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
18029 </div>
18030 <div class="stat-chip" data-raw="{{ comment_lines }}">
18031 <div class="stat-chip-label">Comments</div>
18032 <div class="stat-chip-val">{{ comment_lines }}</div>
18033 <div class="stat-chip-exact"></div>
18034 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
18035 </div>
18036 <div class="stat-chip" data-raw="{{ blank_lines }}">
18037 <div class="stat-chip-label">Blank</div>
18038 <div class="stat-chip-val">{{ blank_lines }}</div>
18039 <div class="stat-chip-exact"></div>
18040 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
18041 </div>
18042 <div class="stat-chip" data-raw="{{ mixed_lines }}">
18043 <div class="stat-chip-label">Mixed separate</div>
18044 <div class="stat-chip-val">{{ mixed_lines }}</div>
18045 <div class="stat-chip-exact"></div>
18046 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
18047 </div>
18048 <div class="stat-chip" data-raw="{{ functions }}">
18049 <div class="stat-chip-label">Functions</div>
18050 <div class="stat-chip-val">{{ functions }}</div>
18051 <div class="stat-chip-exact"></div>
18052 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
18053 </div>
18054 <div class="stat-chip" data-raw="{{ classes }}">
18055 <div class="stat-chip-label">Classes / Types</div>
18056 <div class="stat-chip-val">{{ classes }}</div>
18057 <div class="stat-chip-exact"></div>
18058 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
18059 </div>
18060 <div class="stat-chip" data-raw="{{ variables }}">
18061 <div class="stat-chip-label">Variables</div>
18062 <div class="stat-chip-val">{{ variables }}</div>
18063 <div class="stat-chip-exact"></div>
18064 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
18065 </div>
18066 <div class="stat-chip" data-raw="{{ imports }}">
18067 <div class="stat-chip-label">Imports</div>
18068 <div class="stat-chip-val">{{ imports }}</div>
18069 <div class="stat-chip-exact"></div>
18070 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
18071 </div>
18072 <div class="stat-chip" data-raw="{{ test_count }}">
18073 <div class="stat-chip-label">Tests</div>
18074 <div class="stat-chip-val">{{ test_count }}</div>
18075 <div class="stat-chip-exact"></div>
18076 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
18077 </div>
18078 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
18079 <div class="stat-chip-label">Code density</div>
18080 <div class="stat-chip-val stat-chip-density-val">—</div>
18081 <div class="stat-chip-exact"></div>
18082 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
18083 </div>
18084 <div class="stat-chip" data-raw="{{ files_analyzed }}">
18085 <div class="stat-chip-label">Files analyzed</div>
18086 <div class="stat-chip-val">{{ files_analyzed }}</div>
18087 <div class="stat-chip-exact"></div>
18088 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
18089 </div>
18090 </div>
18091
18092 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
18093 <div class="compare-banner">
18094 <div class="compare-banner-body">
18095 <div class="compare-banner-meta">
18096 <span class="compare-label">Previous scan</span>
18097 <span class="compare-ts">{{ prev_ts }}</span>
18098 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
18099 {% if let Some(prev_code) = prev_run_code_lines %}
18100 <div class="compare-banner-stats" style="margin-top:4px;">
18101 <span>Code before: <strong>{{ prev_code }}</strong></span>
18102 <span class="compare-arrow">→</span>
18103 <span>Code now: <strong>{{ code_lines }}</strong></span>
18104 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
18105 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
18106 </div>
18107 {% endif %}
18108 </div>
18109 {% if delta_lines_added.is_some() %}
18110 <div class="delta-cards-inline">
18111 <div class="delta-card-inline">
18112 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
18113 <div class="delta-card-lbl">lines added</div>
18114 <div class="delta-card-tip">Code lines added since the previous scan</div>
18115 </div>
18116 <div class="delta-card-inline">
18117 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
18118 <div class="delta-card-lbl">lines removed</div>
18119 <div class="delta-card-tip">Code lines removed since the previous scan</div>
18120 </div>
18121 <div class="delta-card-inline">
18122 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
18123 <div class="delta-card-lbl">unmodified lines</div>
18124 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
18125 </div>
18126 <div class="delta-card-inline">
18127 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
18128 <div class="delta-card-lbl">files modified</div>
18129 <div class="delta-card-tip">Files with at least one line changed</div>
18130 </div>
18131 <div class="delta-card-inline">
18132 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
18133 <div class="delta-card-lbl">files added</div>
18134 <div class="delta-card-tip">New files added since the previous scan</div>
18135 </div>
18136 <div class="delta-card-inline">
18137 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
18138 <div class="delta-card-lbl">files removed</div>
18139 <div class="delta-card-tip">Files deleted since the previous scan</div>
18140 </div>
18141 <div class="delta-card-inline">
18142 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
18143 <div class="delta-card-lbl">files unchanged</div>
18144 <div class="delta-card-tip">Files with no changes since the previous scan</div>
18145 </div>
18146 </div>
18147 {% else %}
18148 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
18149 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
18150 </p>
18151 {% endif %}
18152 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
18153 </div>
18154 </div>
18155 {% endif %}{% endif %}
18156
18157 <div class="action-grid">
18158 <div class="action-card">
18159 <h3>HTML report</h3>
18160 <div class="action-buttons">
18161 {% match html_url %}
18162 {% when Some with (url) %}
18163 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
18164 {% when None %}{% endmatch %}
18165 {% match html_download_url %}
18166 {% when Some with (url) %}
18167 <a class="button secondary" href="{{ url }}">Download HTML</a>
18168 {% when None %}{% endmatch %}
18169 {% match html_path %}
18170 {% when Some with (_path) %}{% when None %}{% endmatch %}
18171 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
18172 </div>
18173 </div>
18174 <div class="action-card">
18175 <h3>PDF report</h3>
18176 <div class="action-buttons">
18177 {% match pdf_url %}
18178 {% when Some with (url) %}
18179 {% if pdf_generating %}
18180 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
18181 <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>
18182 Generating PDF…
18183 </button>
18184 {% else %}
18185 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
18186 {% endif %}
18187 {% when None %}
18188 {% match html_url %}
18189 {% when Some with (_hurl) %}
18190 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
18191 <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>
18192 {% when None %}
18193 <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;">
18194 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
18195 </p>
18196 {% endmatch %}
18197 {% endmatch %}
18198 {% match pdf_download_url %}
18199 {% when Some with (url) %}
18200 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
18201 {% when None %}{% endmatch %}
18202 {% match pdf_url %}
18203 {% when Some with (_) %}
18204 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
18205 {% when None %}{% endmatch %}
18206 </div>
18207 </div>
18208 <div class="action-card">
18209 <h3>JSON result</h3>
18210 <div class="action-buttons">
18211 {% match json_url %}
18212 {% when Some with (url) %}
18213 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
18214 {% when None %}{% endmatch %}
18215 {% match json_download_url %}
18216 {% when Some with (url) %}
18217 <a class="button secondary" href="{{ url }}">Download JSON</a>
18218 {% when None %}{% endmatch %}
18219 {% match json_path %}
18220 {% when Some with (_path) %}
18221 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
18222 {% when None %}
18223 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
18224 {% endmatch %}
18225 </div>
18226 </div>
18227 <div class="action-card">
18228 <h3>Scan config</h3>
18229 <div class="action-buttons">
18230 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
18231 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
18232 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
18233 </div>
18234 </div>
18235 {% if confluence_configured %}
18236 <div class="action-card" id="confluenceCard">
18237 <h3>Confluence</h3>
18238 <div class="action-buttons">
18239 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
18240 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
18241 </div>
18242 <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>
18243 </div>
18244 {% endif %}
18245 </div>
18246 {% if confluence_configured %}
18247 <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;">
18248 <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);">
18249 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
18250 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
18251 <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;">
18252 <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>
18253 <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;">
18254 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
18255 <div style="display:flex;gap:10px;justify-content:flex-end;">
18256 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
18257 <button class="button" id="confSubmitBtn" type="button">Post</button>
18258 </div>
18259 </div>
18260 </div>
18261 {% endif %}
18262 <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;">
18263 <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);">
18264 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
18265 <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>
18266 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
18267 <div style="display:flex;gap:18px;justify-content:flex-end;">
18268 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
18269 <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>
18270 </div>
18271 </div>
18272 </div>
18273 {% if !submodule_rows.is_empty() %}
18274 <div class="submodule-panel">
18275 <div class="toolbar-row">
18276 <div>
18277 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
18278 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
18279 </div>
18280 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
18281 </div>
18282 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
18283 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
18284 <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>
18285 <thead>
18286 <tr>
18287 <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>
18288 <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>
18289 <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>
18290 <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>
18291 <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>
18292 <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>
18293 <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>
18294 <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>
18295 </tr>
18296 </thead>
18297 <tbody>
18298 {% for row in submodule_rows %}
18299 <tr>
18300 <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>
18301 <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>
18302 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
18303 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
18304 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
18305 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
18306 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
18307 <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>
18308 </tr>
18309 {% endfor %}
18310 </tbody>
18311 </table>
18312 </div>
18313 </div>
18314 {% endif %}
18315
18316 <div class="metrics-tables-stack">
18317
18318 <div class="metrics-table-wrap">
18319 <div class="metrics-table-title">Files</div>
18320 <table class="metrics-table">
18321 <thead>
18322 <tr>
18323 <th>Metric</th>
18324 <th>This Run</th>
18325 <th>Previous</th>
18326 <th>Change</th>
18327 </tr>
18328 </thead>
18329 <tbody>
18330 <tr>
18331 <td>Files analyzed</td>
18332 <td class="mt-val-large">{{ files_analyzed }}</td>
18333 <td>{{ prev_fa_str }}</td>
18334 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
18335 </tr>
18336 <tr>
18337 <td>Files skipped</td>
18338 <td>{{ files_skipped }}</td>
18339 <td>{{ prev_fs_str }}</td>
18340 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
18341 </tr>
18342 <tr>
18343 <td>Files modified</td>
18344 <td class="mt-val-na">—</td>
18345 <td class="mt-val-na">—</td>
18346 <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
18347 </tr>
18348 <tr>
18349 <td>Files unchanged</td>
18350 <td class="mt-val-na">—</td>
18351 <td class="mt-val-na">—</td>
18352 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
18353 </tr>
18354 </tbody>
18355 </table>
18356 </div>
18357
18358 <div class="metrics-table-wrap">
18359 <div class="metrics-table-title">Line Counts</div>
18360 <table class="metrics-table">
18361 <thead>
18362 <tr>
18363 <th>Metric</th>
18364 <th>This Run</th>
18365 <th>Previous</th>
18366 <th>Change</th>
18367 </tr>
18368 </thead>
18369 <tbody>
18370 <tr>
18371 <td>Physical lines</td>
18372 <td class="mt-val-large">{{ physical_lines }}</td>
18373 <td>{{ prev_pl_str }}</td>
18374 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
18375 </tr>
18376 <tr>
18377 <td>Code lines</td>
18378 <td class="mt-val-large">{{ code_lines }}</td>
18379 <td>{{ prev_cl_str }}</td>
18380 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
18381 </tr>
18382 <tr>
18383 <td>Comment lines</td>
18384 <td>{{ comment_lines }}</td>
18385 <td>{{ prev_cml_str }}</td>
18386 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
18387 </tr>
18388 <tr>
18389 <td>Blank lines</td>
18390 <td>{{ blank_lines }}</td>
18391 <td>{{ prev_bl_str }}</td>
18392 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
18393 </tr>
18394 <tr>
18395 <td>Mixed (separate)</td>
18396 <td>{{ mixed_lines }}</td>
18397 <td class="mt-val-na">—</td>
18398 <td class="mt-val-na">—</td>
18399 </tr>
18400 </tbody>
18401 </table>
18402 </div>
18403
18404 <div class="metrics-tables-lower">
18405 <div class="metrics-table-wrap">
18406 <div class="metrics-table-title">Code Structure</div>
18407 <table class="metrics-table">
18408 <thead>
18409 <tr>
18410 <th>Metric</th>
18411 <th>This Run</th>
18412 </tr>
18413 </thead>
18414 <tbody>
18415 <tr>
18416 <td>Functions</td>
18417 <td>{{ functions }}</td>
18418 </tr>
18419 <tr>
18420 <td>Classes / Types</td>
18421 <td>{{ classes }}</td>
18422 </tr>
18423 <tr>
18424 <td>Variables</td>
18425 <td>{{ variables }}</td>
18426 </tr>
18427 <tr>
18428 <td>Imports</td>
18429 <td>{{ imports }}</td>
18430 </tr>
18431 </tbody>
18432 </table>
18433 </div>
18434
18435 <div class="metrics-table-wrap">
18436 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
18437 <table class="metrics-table">
18438 <thead>
18439 <tr>
18440 <th>Metric</th>
18441 <th>Change</th>
18442 </tr>
18443 </thead>
18444 <tbody>
18445 <tr>
18446 <td>Lines added</td>
18447 <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
18448 </tr>
18449 <tr>
18450 <td>Lines removed</td>
18451 <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">−{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
18452 </tr>
18453 <tr>
18454 <td>Lines modified (net)</td>
18455 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
18456 </tr>
18457 <tr>
18458 <td>Lines unmodified</td>
18459 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
18460 </tr>
18461 </tbody>
18462 </table>
18463 </div>
18464 </div>
18465
18466 </div>
18467
18468 <div class="path-list">
18469 <div class="path-item">
18470 <div class="path-item-label">Project path</div>
18471 <code>{{ project_path }}</code>
18472 </div>
18473 <div class="path-item">
18474 <div class="path-item-label">Git branch</div>
18475 {% if let Some(branch) = git_branch %}
18476 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
18477 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
18478 {% else %}
18479 <code style="color:var(--muted)">—</code>
18480 {% endif %}
18481 </div>
18482 <div class="path-item">
18483 <div class="path-item-label">Output folder</div>
18484 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
18485 </div>
18486 <div class="path-item">
18487 <div class="path-item-label">Run ID</div>
18488 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
18489 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
18490 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
18491 </div>
18492 </div>
18493 </div>
18494 </section>
18495
18496 <div class="section-pair">
18497 <section class="panel">
18498 <div class="toolbar-row">
18499 <div>
18500 <h2>Language breakdown</h2>
18501 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
18502 </div>
18503 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18504 </div>
18505 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
18506 </section>
18507
18508 <section class="panel r-chart-section">
18509 <div class="toolbar-row" style="margin-bottom:16px;">
18510 <div>
18511 <h2>Visualizations</h2>
18512 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
18513 </div>
18514 </div>
18515
18516 <div class="r-viz-grid">
18517 <div class="r-viz-card">
18518 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18519 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
18520 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18521 </div>
18522 <div class="r-chart-tab-bar">
18523 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
18524 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
18525 </div>
18526 <div class="r-chart-container" id="r-composition-chart"></div>
18527 </div>
18528 <div class="r-viz-card">
18529 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18530 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
18531 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18532 </div>
18533 <div class="r-chart-container" id="r-scatter-chart"></div>
18534 </div>
18535 {% if has_semantic_data %}
18536 <div class="r-viz-card">
18537 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18538 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
18539 <select class="r-chart-select" id="r-semantic-metric">
18540 <option value="functions">Functions</option>
18541 <option value="classes">Classes</option>
18542 <option value="variables">Variables</option>
18543 <option value="imports">Imports</option>
18544 <option value="tests">Tests</option>
18545 </select>
18546 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18547 </div>
18548 <div class="r-chart-container" id="r-semantic-chart"></div>
18549 </div>
18550 {% endif %}
18551 <div class="r-viz-card">
18552 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18553 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
18554 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18555 </div>
18556 <div class="r-chart-container" id="r-density-chart"></div>
18557 </div>
18558 <div class="r-viz-card">
18559 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18560 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
18561 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18562 </div>
18563 <div class="r-chart-container" id="r-avglines-chart"></div>
18564 </div>
18565 <div class="r-viz-card">
18566 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
18567 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
18568 <select class="r-chart-select" id="r-sub-metric">
18569 <option value="code">Code Lines</option>
18570 <option value="comment">Comments</option>
18571 <option value="blank">Blank Lines</option>
18572 <option value="physical">Physical Lines</option>
18573 <option value="files">Files</option>
18574 </select>
18575 <select class="r-chart-select" id="r-sub-sort">
18576 <option value="desc">Value ↓</option>
18577 <option value="asc">Value ↑</option>
18578 <option value="name">Name A→Z</option>
18579 </select>
18580 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18581 </div>
18582 <div class="r-chart-container" id="r-submodule-chart"></div>
18583 </div>
18584 </div>
18585
18586 </section>
18587 </div>
18588
18589 </div>
18590
18591 <div id="r-tt" aria-hidden="true"></div>
18592
18593 <script nonce="{{ csp_nonce }}">
18594 (function () {
18595 var body = document.body;
18596 var themeToggle = document.getElementById('theme-toggle');
18597 var storageKey = 'oxide-sloc-theme';
18598
18599 function applyTheme(theme) {
18600 body.classList.toggle('dark-theme', theme === 'dark');
18601 }
18602
18603 function loadSavedTheme() {
18604 try {
18605 var saved = localStorage.getItem(storageKey);
18606 if (saved === 'dark' || saved === 'light') {
18607 applyTheme(saved);
18608 }
18609 } catch (e) {}
18610 }
18611
18612 if (themeToggle) {
18613 themeToggle.addEventListener('click', function () {
18614 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
18615 applyTheme(nextTheme);
18616 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
18617 });
18618 }
18619
18620 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
18621 button.addEventListener('click', function () {
18622 var value = button.getAttribute('data-copy-value') || '';
18623 if (!value) return;
18624 var originalText = button.textContent;
18625 function flashSuccess() {
18626 button.textContent = 'Copied!';
18627 setTimeout(function () { button.textContent = originalText; }, 1800);
18628 }
18629 function flashFail() {
18630 button.textContent = 'Copy failed';
18631 setTimeout(function () { button.textContent = originalText; }, 2000);
18632 }
18633 if (navigator.clipboard && navigator.clipboard.writeText) {
18634 navigator.clipboard.writeText(value).then(flashSuccess, function () {
18635 fallbackCopy(value, flashSuccess, flashFail);
18636 });
18637 } else {
18638 fallbackCopy(value, flashSuccess, flashFail);
18639 }
18640 });
18641 });
18642 function fallbackCopy(text, onSuccess, onFail) {
18643 try {
18644 var ta = document.createElement('textarea');
18645 ta.value = text;
18646 ta.style.position = 'fixed';
18647 ta.style.top = '-9999px';
18648 ta.style.left = '-9999px';
18649 document.body.appendChild(ta);
18650 ta.focus();
18651 ta.select();
18652 var ok = document.execCommand('copy');
18653 document.body.removeChild(ta);
18654 if (ok) { onSuccess(); } else { onFail(); }
18655 } catch (e) { onFail(); }
18656 }
18657
18658 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18659 btn.addEventListener('click', function () {
18660 var folder = btn.getAttribute('data-folder') || '';
18661 if (!folder) return;
18662 var orig = btn.textContent;
18663 fetch('/open-path?path=' + encodeURIComponent(folder))
18664 .then(function (r) { return r.json(); })
18665 .then(function (d) {
18666 if (d && d.server_mode_disabled) {
18667 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18668 } else if (d && d.ok) {
18669 btn.textContent = 'Opened!';
18670 setTimeout(function () { btn.textContent = orig; }, 1800);
18671 }
18672 })
18673 .catch(function () {
18674 btn.textContent = 'Failed';
18675 setTimeout(function () { btn.textContent = orig; }, 2000);
18676 });
18677 });
18678 });
18679
18680 loadSavedTheme();
18681
18682 // ── Compact number formatting for stat chips ──────────────────────────
18683 (function(){
18684 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();}
18685 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
18686 var raw=parseInt(chip.getAttribute('data-raw'),10);
18687 if(isNaN(raw))return;
18688 var valEl=chip.querySelector('.stat-chip-val');
18689 if(valEl)valEl.textContent=fmt(raw);
18690 var exactEl=chip.querySelector('.stat-chip-exact');
18691 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
18692 });
18693 // Code density chip
18694 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
18695 var code=parseInt(chip.getAttribute('data-code'),10);
18696 var phys=parseInt(chip.getAttribute('data-physical'),10);
18697 if(isNaN(code)||isNaN(phys)||phys===0)return;
18698 var pct=(code/phys*100).toFixed(1)+'%';
18699 var valEl=chip.querySelector('.stat-chip-val');
18700 if(valEl)valEl.textContent=pct;
18701 });
18702 // Populate author handle from data-author attribute
18703 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
18704 var author=chip.getAttribute('data-author');
18705 var el=chip.querySelector('.author-handle');
18706 if(el)el.textContent='/'+author.replace(/\s+/g,'');
18707 });
18708 // Click-to-copy on run-id-chip elements
18709 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
18710 chip.addEventListener('click',function(){
18711 var val=chip.getAttribute('data-copy');
18712 if(!val)return;
18713 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
18714 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);}
18715 chip.classList.add('chip-copied-flash');
18716 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
18717 });
18718 });
18719 })();
18720
18721 // ── Shared tooltip for all result-page charts ─────────────────────────
18722 var rTT=(function(){
18723 var el=document.getElementById('r-tt');
18724 if(!el)return{s:function(){},h:function(){},m:function(){}};
18725 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
18726 function hide(){el.style.display='none';}
18727 function move(e){
18728 var x=e.clientX+16,y=e.clientY-12;
18729 var r=el.getBoundingClientRect();
18730 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
18731 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
18732 el.style.left=x+'px';el.style.top=y+'px';
18733 }
18734 return{s:show,h:hide,m:move};
18735 })();
18736 window.rTT=rTT;
18737
18738 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
18739 (function(){
18740 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18741 document.addEventListener('mouseover',function(e){
18742 var t=e.target;
18743 while(t&&t.getAttribute){
18744 var l=t.getAttribute('data-ttl');
18745 if(l!==null){
18746 var v=t.getAttribute('data-ttv')||'';
18747 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
18748 return;
18749 }
18750 t=t.parentNode;
18751 }
18752 });
18753 document.addEventListener('mouseout',function(e){
18754 var t=e.target;
18755 while(t&&t.getAttribute){
18756 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
18757 t=t.parentNode;
18758 }
18759 });
18760 document.addEventListener('mousemove',function(e){
18761 var el=document.getElementById('r-tt');
18762 if(el&&el.style.display!=='none')rTT.m(e);
18763 });
18764 window.addEventListener('blur',function(){rTT.h();});
18765 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
18766 })();
18767
18768 // ── Language overview charts ───────────────────────────────────────────
18769 (function(){
18770 var D={{ lang_chart_json|safe }};
18771 if(!D||!D.length)return;
18772 var el=document.getElementById('result-lang-charts');
18773 if(!el)return;
18774 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18775 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
18776 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18777 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();}
18778 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18779 function px(n){return Math.round(n);}
18780 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+'"';}
18781 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
18782
18783 // Donut chart — height matches the stacked-bar chart so both panels align
18784 var rHb_d=28;
18785 var DH=Math.max(220,D.length*rHb_d+32);
18786 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
18787 var legX=204,DW=360;
18788 var legCount=D.length;
18789 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
18790 var legYStart=Math.round((DH-legCount*legSpacing)/2);
18791 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">';
18792 if(D.length===1){
18793 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
18794 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+'"/>';
18795 } else {
18796 var ang=-Math.PI/2;
18797 D.forEach(function(d,i){
18798 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18799 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
18800 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
18801 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
18802 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
18803 var pct=Math.round(d.code/tot*100);
18804 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"/>';
18805 ang+=sw;
18806 });
18807 }
18808 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
18809 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
18810 D.forEach(function(d,i){
18811 var ly=legYStart+i*legSpacing;
18812 var pctL=Math.round(d.code/tot*100);
18813 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
18814 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
18815 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
18816 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
18817 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
18818 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
18819 ds+='</g>';
18820 });
18821 ds+='</svg>';
18822
18823 // Horizontal stacked-bar chart — fills container width
18824 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
18825 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
18826 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">';
18827 D.forEach(function(d,i){
18828 var y=6+i*rHb,x=LW;
18829 var phys=d.physical||d.code+d.comments+d.blanks;
18830 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
18831 bs+='<g class="lang-bar-row">';
18832 bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
18833 bs+='<text x="'+(LW-6)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
18834 if(cW>0.5)bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'" rx="0"/>';x+=cW;
18835 if(cmW>0.5)bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'" rx="0"/>';x+=cmW;
18836 if(blW>0.5)bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'" rx="0"/>';
18837 bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
18838 bs+='</g>';
18839 });
18840 var ly=SH-14;
18841 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
18842 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
18843 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
18844 var totAll=totC+totCm+totBl||1;
18845 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
18846 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
18847 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
18848 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
18849 bs+='<g data-kind="code" style="cursor:pointer;">'
18850 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
18851 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
18852 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
18853 +'</g>';
18854 bs+='<g data-kind="comment" style="cursor:pointer;">'
18855 +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
18856 +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
18857 +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
18858 +'</g>';
18859 bs+='<g data-kind="blank" style="cursor:pointer;">'
18860 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
18861 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
18862 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
18863 +'</g>';
18864 bs+='</svg>';
18865 el.innerHTML='<div class="r-lang-overview">'+
18866 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
18867 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
18868 '</div>';
18869 function wireDonutLegend(svg){
18870 if(!svg)return;
18871 var paths=svg.querySelectorAll('path[data-lang]');
18872 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';}}}
18873 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
18874 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;}});
18875 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
18876 }
18877 function wireMixLegend(svg){
18878 if(!svg)return;
18879 var legGs=svg.querySelectorAll('g[data-kind]');
18880 var allRects=svg.querySelectorAll('rect[data-kind]');
18881 if(!legGs.length)return;
18882 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';}}
18883 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='';}}
18884 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]);}
18885 }
18886 wireDonutLegend(el.querySelector('svg'));
18887 wireMixLegend(el.querySelectorAll('svg')[1]);
18888
18889 // ── Language breakdown Full View expand ─────────────────────────────────
18890 var langOvBtn=document.getElementById('result-lang-overview-expand');
18891 if(langOvBtn){langOvBtn.addEventListener('click',function(){
18892 var src=document.getElementById('result-lang-charts');
18893 if(!src)return;
18894 var overlay=document.createElement('div');
18895 overlay.className='r-chart-modal-overlay';
18896 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown — Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
18897 document.body.appendChild(overlay);
18898 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18899 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18900 var wrap=document.getElementById('result-lang-overview-modal-wrap');
18901 if(wrap){
18902 wrap.innerHTML=src.innerHTML;
18903 var svgs=wrap.querySelectorAll('svg');
18904 for(var i=0;i<svgs.length;i++){
18905 svgs[i].removeAttribute('width');
18906 svgs[i].removeAttribute('height');
18907 svgs[i].style.cssText='display:block;width:100%;height:auto;';
18908 }
18909 var ov=wrap.querySelector('.r-lang-overview');
18910 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
18911 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
18912 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
18913 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
18914 wireDonutLegend(wrap.querySelector('svg'));
18915 wireMixLegend(wrap.querySelectorAll('svg')[1]);
18916 requestAnimationFrame(function(){
18917 var ss=wrap.querySelectorAll('svg');
18918 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%;';}}
18919 });
18920 }
18921 });}
18922 })();
18923
18924 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
18925 (function(){
18926 var LANG_D={{ lang_chart_json|safe }};
18927 var SCAT_D={{ scatter_chart_json|safe }};
18928 var SEM_D={{ semantic_chart_json|safe }};
18929 var SUB_D={{ submodule_chart_json|safe }};
18930 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
18931 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18932 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();}
18933 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18934 function px(n){return Math.round(n);}
18935 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+'"';}
18936
18937 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
18938 function renderCompositionInEl(el,mode,shOvr){
18939 if(!el||!LANG_D||!LANG_D.length)return;
18940 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18941 var LW=110,SH=shOvr||224;
18942 var svgW=Math.max(320,el.offsetWidth||480);
18943 var BW=Math.max(120,svgW-LW-80);
18944 var legendH=24,topPad=4;
18945 var n=LANG_D.length||1;
18946 var rowTotal=Math.floor((SH-legendH-topPad)/n);
18947 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18948 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">';
18949 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
18950 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
18951 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
18952 var totAll2=totC2+totCm2+totBl2||1;
18953 if(mode==='pct'){
18954 LANG_D.forEach(function(d,i){
18955 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
18956 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
18957 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18958 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>';
18959 if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
18960 if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
18961 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+'"/>';
18962 var pct=Math.round((d.code||0)/tot2*100);
18963 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+pct+'%</text>';
18964 });
18965 } else {
18966 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
18967 LANG_D.forEach(function(d,i){
18968 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
18969 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18970 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>';
18971 if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
18972 if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
18973 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+'"/>';
18974 s+='<text x="'+(LW+cW+cmW+blW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
18975 });
18976 }
18977 var ly=SH-legendH+4;
18978 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
18979 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
18980 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
18981 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
18982 s+='<g data-kind="code" style="cursor:pointer;">'
18983 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
18984 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
18985 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
18986 +'</g>';
18987 s+='<g data-kind="comment" style="cursor:pointer;">'
18988 +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
18989 +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
18990 +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
18991 +'</g>';
18992 s+='<g data-kind="blank" style="cursor:pointer;">'
18993 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
18994 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
18995 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
18996 +'</g>';
18997 s+='</svg>';
18998 el.innerHTML=s;
18999 wireMixLegendEl(el);
19000 }
19001 function wireMixLegendEl(container){
19002 var svg=container&&container.querySelector('svg');
19003 if(!svg)return;
19004 var legGs=svg.querySelectorAll('g[data-kind]');
19005 var allRects=svg.querySelectorAll('rect[data-kind]');
19006 if(!legGs.length)return;
19007 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';}}
19008 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='';}}
19009 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]);}
19010 }
19011 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
19012 renderComposition('abs');
19013 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
19014 btn.addEventListener('click',function(){
19015 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
19016 btn.classList.add('active');
19017 renderComposition(btn.getAttribute('data-rcomp'));
19018 });
19019 });
19020
19021 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
19022 function renderScatterInEl(el,hOvr){
19023 if(!el||!SCAT_D||!SCAT_D.length)return;
19024 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
19025 var W=Math.max(320,el.offsetWidth||480);
19026 var cW=W-PL-PR,cH=H-PT-PB;
19027 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
19028 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
19029 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
19030 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">';
19031 [0,0.25,0.5,0.75,1].forEach(function(t){
19032 var y=PT+cH*(1-t);
19033 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
19034 if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';
19035 });
19036 [0,0.25,0.5,0.75,1].forEach(function(t){
19037 var x=PL+cW*t;
19038 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
19039 if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';
19040 });
19041 SCAT_D.forEach(function(d,i){
19042 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
19043 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
19044 s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
19045 if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';
19046 });
19047 s+='<text x="'+(PL+cW/2)+'" y="'+(H-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7">Files</text>';
19048 s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
19049 s+='</svg>';
19050 el.innerHTML=s;
19051 }
19052 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
19053
19054 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
19055 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
19056 // the old vertical column layout on wide containers.
19057 function renderSemanticInEl(el,key,sh){
19058 if(!el||!SEM_D||!SEM_D.length)return;
19059 var n2=SEM_D.length||1;
19060 var LW=112,SH=sh||Math.max(180,n2*28+26);
19061 var svgW=Math.max(320,el.offsetWidth||480);
19062 var BW=Math.max(120,svgW-LW-80);
19063 var topPad=4,botPad=14;
19064 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
19065 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
19066 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
19067 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">';
19068 SEM_D.forEach(function(d,i){
19069 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
19070 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>';
19071 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"/>';
19072 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>';
19073 });
19074 s+='</svg>';
19075 el.innerHTML=s;
19076 }
19077 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
19078 var semSel=document.getElementById('r-semantic-metric');
19079 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
19080 var semExpand=document.getElementById('r-semantic-expand');
19081 if(semExpand){
19082 semExpand.addEventListener('click',function(){
19083 var key=semSel?semSel.value:'functions';
19084 var n=SEM_D.length||1;
19085 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
19086 var modalH=Math.min(Math.max(360,n*38+60),maxH);
19087 var overlay=document.createElement('div');
19088 overlay.className='r-chart-modal-overlay';
19089 var optHtml=
19090 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
19091 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
19092 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
19093 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
19094 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
19095 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics — Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
19096 document.body.appendChild(overlay);
19097 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
19098 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
19099 var modalEl=document.getElementById('r-sem-modal-chart');
19100 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
19101 var modalSel=document.getElementById('r-sem-modal-metric');
19102 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
19103 });
19104 }
19105
19106 // ── Expand buttons: re-render charts at large size inside modal ──────────
19107 (function(){
19108 function makeExpandModal(title,mH,subtitle,ctrlHtml){
19109 var overlay=document.createElement('div');
19110 overlay.className='r-chart-modal-overlay';
19111 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
19112 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
19113 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>';
19114 document.body.appendChild(overlay);
19115 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
19116 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
19117 return overlay.querySelector('.r-expand-modal-chart');
19118 }
19119 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
19120 var compExpandBtn=document.getElementById('r-composition-expand');
19121 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
19122 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
19123 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
19124 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
19125 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
19126 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
19127 if(wrap){
19128 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
19129 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
19130 btn.addEventListener('click',function(){
19131 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
19132 btn.classList.add('active');
19133 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
19134 });
19135 });
19136 }
19137 });}
19138 var scatExpandBtn=document.getElementById('r-scatter-expand');
19139 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
19140 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
19141 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
19142 });}
19143 var densExpandBtn=document.getElementById('r-density-expand');
19144 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
19145 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
19146 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
19147 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
19148 });}
19149 var avgExpandBtn=document.getElementById('r-avglines-expand');
19150 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
19151 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
19152 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
19153 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
19154 });}
19155 var subExpandBtn=document.getElementById('r-submodule-expand');
19156 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
19157 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
19158 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
19159 var metCtrl=
19160 '<select class="r-chart-select" id="r-sub-modal-metric">'
19161 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
19162 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
19163 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
19164 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
19165 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
19166 +'</select>';
19167 var sortCtrl=
19168 '<select class="r-chart-select" id="r-sub-modal-sort">'
19169 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
19170 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
19171 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
19172 +'</select>';
19173 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
19174 if(wrap){
19175 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
19176 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
19177 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
19178 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
19179 if(mSub)mSub.addEventListener('change',reRenderSub);
19180 if(mSort)mSort.addEventListener('change',reRenderSub);
19181 }
19182 });}
19183 })();
19184
19185 // ── Comment Density: comments / (code + comments) per language ───────────
19186 function renderDensityInEl(el,shOvr){
19187 if(!el||!LANG_D||!LANG_D.length)return;
19188 var n=LANG_D.length||1;
19189 var LW=112,SH=shOvr||Math.max(180,n*28+26);
19190 var svgW=Math.max(320,el.offsetWidth||480);
19191 var BW=Math.max(120,svgW-LW-80);
19192 var topPad=4,botPad=26;
19193 var rowTotal=Math.floor((SH-topPad-botPad)/n);
19194 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
19195 var densities=LANG_D.map(function(d){
19196 var sig=(d.code||0)+(d.comments||0);
19197 return sig>0?(d.comments||0)/sig:0;
19198 });
19199 var maxDen=Math.max.apply(null,densities)||1;
19200 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">';
19201 LANG_D.forEach(function(d,i){
19202 var den=densities[i],bw=den/maxDen*BW;
19203 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
19204 var pct=Math.round(den*100);
19205 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>';
19206 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"/>';
19207 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19208 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>';
19209 });
19210 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>';
19211 s+='</svg>';
19212 el.innerHTML=s;
19213 }
19214 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
19215 renderDensity();
19216
19217 // ── Avg Lines per File: code / files per language ─────────────────────
19218 function renderAvgLinesInEl(el,shOvr){
19219 if(!el||!LANG_D||!LANG_D.length)return;
19220 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
19221 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
19222 var n=data.length||1;
19223 var LW=112,SH=shOvr||Math.max(180,n*28+26);
19224 var svgW=Math.max(320,el.offsetWidth||480);
19225 var BW=Math.max(120,svgW-LW-80);
19226 var topPad=4,botPad=26;
19227 var rowTotal=Math.floor((SH-topPad-botPad)/n);
19228 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
19229 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
19230 var maxAvg=Math.max.apply(null,avgs)||1;
19231 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">';
19232 data.forEach(function(d,i){
19233 var avg=avgs[i],bw=avg/maxAvg*BW;
19234 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
19235 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>';
19236 if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
19237 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19238 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>';
19239 });
19240 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>';
19241 s+='</svg>';
19242 el.innerHTML=s;
19243 }
19244 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
19245 renderAvgLines();
19246
19247 // ── Repository Overview: overall row + per-submodule rows ────────────
19248 function renderSubmoduleInEl(el,key,sort,shOvr){
19249 if(!el)return;
19250 var overall={
19251 name:'Overall',
19252 code:{{ code_lines }},
19253 comment:{{ comment_lines }},
19254 blank:{{ blank_lines }},
19255 physical:{{ physical_lines }},
19256 files:{{ files_analyzed }},
19257 isOverall:true
19258 };
19259 var subs=SUB_D.slice();
19260 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
19261 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
19262 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
19263 var data=[overall].concat(subs);
19264 var rowH=32,bH=22,sepH=subs.length>0?14:0;
19265 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
19266 var svgW=Math.max(320,el.offsetWidth||480);
19267 var LW=116,BW=Math.max(200,svgW-LW-54);
19268 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
19269 var OVERALL_COL='#6b7280';
19270 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">';
19271 var yOff=4;
19272 data.forEach(function(d,i){
19273 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
19274 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
19275 var label=d.name||d.path||'?';
19276 s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
19277 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
19278 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19279 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
19280 yOff+=rowH;
19281 if(d.isOverall&&subs.length>0){
19282 yOff+=sepH;
19283 }
19284 });
19285 s+='</svg>';
19286 el.innerHTML=s;
19287 }
19288 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
19289 var subSel=document.getElementById('r-sub-metric');
19290 var sortSel=document.getElementById('r-sub-sort');
19291 renderSubmodule('code','desc');
19292 if(subSel){
19293 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
19294 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
19295 }
19296
19297 // Equalise heights within each chart row: if one chart in a grid row is taller
19298 // than its neighbour, re-render the shorter one at the taller height so bars fill
19299 // the available vertical space instead of leaving a gap.
19300 function syncRowHeights(){
19301 var avgEl=document.getElementById('r-avglines-chart');
19302 var subEl=document.getElementById('r-submodule-chart');
19303 if(avgEl&&subEl){
19304 var avgSvg=avgEl.querySelector('svg');
19305 var subSvg=subEl.querySelector('svg');
19306 if(avgSvg&&subSvg){
19307 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
19308 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
19309 var key=subSel?subSel.value||'code':'code';
19310 var sort=sortSel?sortSel.value:'desc';
19311 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
19312 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
19313 }
19314 }
19315 var semEl=document.getElementById('r-semantic-chart');
19316 var denEl=document.getElementById('r-density-chart');
19317 if(semEl&&denEl){
19318 var semSvg=semEl.querySelector('svg');
19319 var denSvg=denEl.querySelector('svg');
19320 if(semSvg&&denSvg){
19321 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
19322 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
19323 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
19324 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
19325 }
19326 }
19327 }
19328 syncRowHeights();
19329
19330 // Re-render all SVG charts when the window is resized so bars fill the card.
19331 var _rResizeTimer;
19332 window.addEventListener('resize',function(){
19333 clearTimeout(_rResizeTimer);
19334 _rResizeTimer=setTimeout(function(){
19335 var rcompBtn=document.querySelector('[data-rcomp].active');
19336 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
19337 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
19338 if(semSel)renderSemantic(semSel.value||'functions');
19339 renderDensity();
19340 renderAvgLines();
19341 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
19342 syncRowHeights();
19343 },120);
19344 });
19345 })();
19346
19347 (function randomizeWatermarks() {
19348 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
19349 if (!wms.length) return;
19350 var placed = [];
19351 function tooClose(top, left) {
19352 for (var i = 0; i < placed.length; i++) {
19353 var dt = Math.abs(placed[i][0] - top);
19354 var dl = Math.abs(placed[i][1] - left);
19355 if (dt < 20 && dl < 18) return true;
19356 }
19357 return false;
19358 }
19359 function pick(leftBand) {
19360 for (var attempt = 0; attempt < 50; attempt++) {
19361 var top = Math.random() * 85 + 5;
19362 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19363 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19364 }
19365 var top = Math.random() * 85 + 5;
19366 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19367 placed.push([top, left]);
19368 return [top, left];
19369 }
19370 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
19371 var half = Math.floor(wms.length / 2);
19372 wms.forEach(function (img, i) {
19373 var pos = pick(i < half);
19374 var size = Math.floor(Math.random() * 100 + 160);
19375 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
19376 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
19377 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;
19378 });
19379 })();
19380
19381 (function spawnCodeParticles() {
19382 var container = document.getElementById('code-particles');
19383 if (!container) return;
19384 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'];
19385 for (var i = 0; i < 38; i++) {
19386 (function(idx) {
19387 var el = document.createElement('span');
19388 el.className = 'code-particle';
19389 el.textContent = snippets[idx % snippets.length];
19390 var left = Math.random() * 94 + 2;
19391 var top = Math.random() * 88 + 6;
19392 var dur = (Math.random() * 10 + 9).toFixed(1);
19393 var delay = (Math.random() * 18).toFixed(1);
19394 var rot = (Math.random() * 26 - 13).toFixed(1);
19395 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19396 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';
19397 container.appendChild(el);
19398 })(i);
19399 }
19400 })();
19401
19402 {% if pdf_generating %}
19403 // Poll for PDF readiness and swap the disabled button to a live link once done.
19404 (function() {
19405 var openBtn = document.getElementById('pdf-open-btn');
19406 var dlBtn = document.getElementById('pdf-download-btn');
19407 function checkPdf() {
19408 fetch('/api/runs/{{ run_id }}/pdf-status')
19409 .then(function(r) { return r.json(); })
19410 .then(function(d) {
19411 if (d.ready) {
19412 if (openBtn) {
19413 var a = document.createElement('a');
19414 a.className = 'button';
19415 a.id = 'pdf-open-btn';
19416 a.href = '/runs/pdf/{{ run_id }}';
19417 a.target = '_blank';
19418 a.rel = 'noopener';
19419 a.textContent = 'Open PDF';
19420 openBtn.replaceWith(a);
19421 }
19422 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
19423 } else {
19424 setTimeout(checkPdf, 3000);
19425 }
19426 })
19427 .catch(function() { setTimeout(checkPdf, 5000); });
19428 }
19429 setTimeout(checkPdf, 3000);
19430 })();
19431 {% endif %}
19432
19433 })();
19434 </script>
19435 <script nonce="{{ csp_nonce }}">
19436 (function(){
19437 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'}];
19438 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);});}
19439 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19440 function init(){
19441 var btn=document.getElementById('settings-btn');if(!btn)return;
19442 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19443 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>';
19444 document.body.appendChild(m);
19445 var g=document.getElementById('scheme-grid');
19446 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);});
19447 var cl=document.getElementById('settings-close');
19448 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);
19449 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');});
19450 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19451 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19452 }
19453 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19454 }());
19455 </script>
19456 <footer class="site-footer">
19457 local code analysis - metrics, history and reports
19458 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19459 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19460 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19461 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19462 · <a href="/api-docs" rel="noopener">REST API</a>
19463 </footer>
19464 {% if confluence_configured %}
19465 <script nonce="{{ csp_nonce }}">
19466 (function() {
19467 var postBtn = document.getElementById('postConfluenceBtn');
19468 var copyBtn = document.getElementById('copyWikiBtn');
19469 var modal = document.getElementById('confluenceModal');
19470 if (!postBtn || !modal) return;
19471
19472 postBtn.addEventListener('click', function() {
19473 document.getElementById('confStatus').style.display = 'none';
19474 modal.style.display = 'flex';
19475 });
19476 document.getElementById('confCancelBtn').addEventListener('click', function() {
19477 modal.style.display = 'none';
19478 });
19479 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19480
19481 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
19482 var btn = this;
19483 btn.disabled = true;
19484 var status = document.getElementById('confStatus');
19485 status.style.display = 'block';
19486 status.style.background = '#dbeafe';
19487 status.style.color = '#1e40af';
19488 status.textContent = 'Posting to Confluence…';
19489 var resp = await fetch('/api/confluence/post', {
19490 method: 'POST',
19491 headers: { 'Content-Type': 'application/json' },
19492 body: JSON.stringify({
19493 run_id: '{{ run_id }}',
19494 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
19495 report_url: document.getElementById('confReportUrl').value.trim() || null
19496 })
19497 });
19498 var data = await resp.json();
19499 if (data.ok) {
19500 status.style.background = '#dcfce7'; status.style.color = '#166534';
19501 status.textContent = 'Posted! Page ID: ' + data.page_id;
19502 } else {
19503 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19504 status.textContent = 'Error: ' + (data.error || 'Unknown error');
19505 }
19506 btn.disabled = false;
19507 });
19508
19509 if (copyBtn) {
19510 copyBtn.addEventListener('click', async function() {
19511 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
19512 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
19513 var text = await resp.text();
19514 try {
19515 await navigator.clipboard.writeText(text);
19516 var orig = copyBtn.textContent;
19517 copyBtn.textContent = 'Copied!';
19518 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
19519 } catch(e) {
19520 alert('Clipboard write failed — check browser permissions.');
19521 }
19522 });
19523 }
19524 })();
19525 </script>
19526 {% endif %}
19527 <script nonce="{{ csp_nonce }}">
19528 (function() {
19529 var deleteBtn = document.getElementById('delete-run-btn');
19530 var modal = document.getElementById('delete-run-modal');
19531 var cancelBtn = document.getElementById('delete-run-cancel');
19532 var confirmBtn= document.getElementById('delete-run-confirm');
19533 if (!deleteBtn || !modal) return;
19534 deleteBtn.addEventListener('click', function() {
19535 document.getElementById('delete-run-status').style.display = 'none';
19536 modal.style.display = 'flex';
19537 });
19538 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
19539 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19540 confirmBtn.addEventListener('click', async function() {
19541 confirmBtn.disabled = true;
19542 cancelBtn.disabled = true;
19543 var status = document.getElementById('delete-run-status');
19544 status.style.display = 'block';
19545 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
19546 status.textContent = 'Deleting…';
19547 try {
19548 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
19549 if (resp.status === 204 || resp.ok) {
19550 status.style.background = '#dcfce7'; status.style.color = '#166534';
19551 status.textContent = 'Deleted. Redirecting…';
19552 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
19553 } else {
19554 var d = await resp.json().catch(function(){return {};});
19555 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19556 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
19557 confirmBtn.disabled = false;
19558 cancelBtn.disabled = false;
19559 }
19560 } catch (e) {
19561 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19562 status.textContent = 'Network error: ' + String(e);
19563 confirmBtn.disabled = false;
19564 cancelBtn.disabled = false;
19565 }
19566 });
19567 })();
19568 </script>
19569 <script nonce="{{ csp_nonce }}">(function(){
19570 var bundleBtn = document.getElementById('download-bundle-btn');
19571 if (bundleBtn) {
19572 bundleBtn.addEventListener('click', function() {
19573 bundleBtn.disabled = true;
19574 var orig = bundleBtn.textContent;
19575 bundleBtn.textContent = 'Preparing…';
19576 fetch('/api/runs/{{ run_id }}/bundle')
19577 .then(function(r) {
19578 if (!r.ok) throw new Error('HTTP ' + r.status);
19579 return r.blob();
19580 })
19581 .then(function(blob) {
19582 var url = URL.createObjectURL(blob);
19583 var a = document.createElement('a');
19584 a.href = url;
19585 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
19586 document.body.appendChild(a);
19587 a.click();
19588 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
19589 bundleBtn.disabled = false;
19590 bundleBtn.textContent = orig;
19591 })
19592 .catch(function(e) {
19593 bundleBtn.disabled = false;
19594 bundleBtn.textContent = orig;
19595 alert('Bundle download failed: ' + String(e));
19596 });
19597 });
19598 }
19599 })();</script>
19600 <script nonce="{{ csp_nonce }}">(function(){
19601 var dot=document.getElementById('status-dot');
19602 var pingEl=document.getElementById('server-ping-ms');
19603 var tipEl=document.getElementById('server-tip-ping');
19604 var fm=document.getElementById('footer-mode');
19605 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)';}}
19606 function doPing(){
19607 var t0=performance.now();
19608 fetch('/healthz',{cache:'no-store'})
19609 .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);})
19610 .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)';}});
19611 }
19612 doPing();
19613 setInterval(doPing,5000);
19614 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
19615 })();</script>
19616 {% if let Some(banner) = report_header_footer %}
19617 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
19618 {% endif %}
19619</body>
19620</html>
19621"##,
19622 ext = "html"
19623)]
19624#[allow(clippy::struct_excessive_bools)]
19626struct ResultTemplate {
19627 version: &'static str,
19628 report_title: String,
19629 project_path: String,
19630 output_dir: String,
19631 run_id: String,
19632 files_analyzed: u64,
19633 files_skipped: u64,
19634 physical_lines: u64,
19635 code_lines: u64,
19636 comment_lines: u64,
19637 blank_lines: u64,
19638 mixed_lines: u64,
19639 functions: u64,
19640 classes: u64,
19641 variables: u64,
19642 imports: u64,
19643 html_url: Option<String>,
19644 pdf_url: Option<String>,
19645 json_url: Option<String>,
19646 html_download_url: Option<String>,
19647 pdf_download_url: Option<String>,
19648 json_download_url: Option<String>,
19649 html_path: Option<String>,
19650 json_path: Option<String>,
19651 prev_run_id: Option<String>,
19652 prev_run_timestamp: Option<String>,
19653 prev_run_code_lines: Option<u64>,
19654 prev_fa_str: String,
19656 prev_fs_str: String,
19657 prev_pl_str: String,
19658 prev_cl_str: String,
19659 prev_cml_str: String,
19660 prev_bl_str: String,
19661 delta_fa_str: String,
19663 delta_fa_class: String,
19664 delta_fs_str: String,
19665 delta_fs_class: String,
19666 delta_pl_str: String,
19667 delta_pl_class: String,
19668 delta_cl_str: String,
19669 delta_cl_class: String,
19670 delta_cml_str: String,
19671 delta_cml_class: String,
19672 delta_bl_str: String,
19673 delta_bl_class: String,
19674 delta_lines_added: Option<i64>,
19676 delta_lines_removed: Option<i64>,
19677 delta_lines_net_str: String,
19678 delta_lines_net_class: String,
19679 delta_files_added: Option<usize>,
19680 delta_files_removed: Option<usize>,
19681 delta_files_modified: Option<usize>,
19682 delta_files_unchanged: Option<usize>,
19683 delta_unmodified_lines: Option<u64>,
19684 git_branch: Option<String>,
19686 git_branch_url: Option<String>,
19687 git_commit: Option<String>,
19688 git_commit_long: Option<String>,
19689 git_author: Option<String>,
19690 git_commit_url: Option<String>,
19691 scan_performed_by: String,
19693 scan_time_display: String,
19694 os_display: String,
19695 test_count: u64,
19696 prev_scan_count: usize,
19698 current_scan_number: usize,
19699 submodule_rows: Vec<SubmoduleRow>,
19701 scan_config_url: String,
19702 lang_chart_json: String,
19703 #[allow(dead_code)]
19705 scatter_chart_json: String,
19706 #[allow(dead_code)]
19707 semantic_chart_json: String,
19708 #[allow(dead_code)]
19709 submodule_chart_json: String,
19710 #[allow(dead_code)]
19711 has_submodule_data: bool,
19712 #[allow(dead_code)]
19713 has_semantic_data: bool,
19714 pdf_generating: bool,
19715 csp_nonce: String,
19716 confluence_configured: bool,
19718 server_mode: bool,
19719 report_header_footer: Option<String>,
19721 run_id_short: String,
19722 #[allow(dead_code)]
19724 is_offline: bool,
19725}
19726
19727#[derive(Template)]
19728#[template(
19729 source = r##"
19730<!doctype html>
19731<html lang="en">
19732<head>
19733 <meta charset="utf-8">
19734 <meta name="viewport" content="width=device-width, initial-scale=1">
19735 <title>OxideSLOC | Analyzing…</title>
19736 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19737 <style nonce="{{ csp_nonce }}">
19738 :root {
19739 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19740 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19741 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
19742 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19743 }
19744 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19745 *{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;}
19746 .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);}
19747 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19748 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
19749 .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));}
19750 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19751 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19752 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19753 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19754 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19755 @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; } }
19756 .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;}
19757 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19758 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19759 .page-body{padding:32px 24px 36px;}
19760 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
19761 .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;}
19762 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
19763 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
19764 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
19765 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
19766 .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;}
19767 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
19768 .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;}
19769 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
19770 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
19771 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
19772 .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;}
19773 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
19774 .hidden{display:none!important;}
19775 .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;}
19776 .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;}
19777 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
19778 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
19779 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
19780 .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);}
19781 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
19782 .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;}
19783 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
19784 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19785 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19786 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
19787 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19788 .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;}
19789 @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));}}
19790 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19791 .site-footer a{color:var(--muted);}
19792 .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;}
19793 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
19794 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
19795 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
19796 </style>
19797</head>
19798<body>
19799 <div class="background-watermarks" aria-hidden="true">
19800 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19801 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19802 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19803 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19804 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19805 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19806 </div>
19807 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19808 <nav class="top-nav">
19809 <div class="top-nav-inner">
19810 <a href="/" class="brand">
19811 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
19812 <div class="brand-copy">
19813 <h1 class="brand-title">OxideSLOC</h1>
19814 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
19815 </div>
19816 </a>
19817 <div class="nav-right">
19818 <a class="nav-pill" href="/">Home</a>
19819 <div class="nav-dropdown">
19820 <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>
19821 <div class="nav-dropdown-menu">
19822 <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>
19823 </div>
19824 </div>
19825 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19826 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19827 <div class="nav-dropdown">
19828 <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>
19829 <div class="nav-dropdown-menu">
19830 <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>
19831 </div>
19832 </div>
19833 <div class="server-status-wrap" id="server-status-wrap">
19834 <div class="nav-pill server-online-pill" id="server-status-pill">
19835 <span class="status-dot" id="status-dot"></span>
19836 <span id="server-status-label">Server</span>
19837 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19838 </div>
19839 <div class="server-status-tip">
19840 OxideSLOC is running — accessible on your network.
19841 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19842 </div>
19843 </div>
19844 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19845 <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>
19846 </button>
19847 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19848 <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>
19849 <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>
19850 </button>
19851 </div>
19852 </div>
19853 </nav>
19854 <div class="page-body">
19855 <div class="wait-panel">
19856 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
19857 <h2 class="wait-title">Analyzing your project…</h2>
19858 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
19859 <div class="path-block">{{ project_path }}</div>
19860 <div class="metrics-row">
19861 <div class="metric-card">
19862 <div class="metric-label">Elapsed</div>
19863 <div class="metric-value" id="elapsed">0s</div>
19864 </div>
19865 <div class="metric-card">
19866 <div class="metric-label">Phase</div>
19867 <div class="metric-value" id="phase">Starting</div>
19868 </div>
19869 <div class="metric-card hidden" id="files-card">
19870 <div class="metric-label">Files</div>
19871 <div class="metric-value" id="files-progress">0</div>
19872 </div>
19873 </div>
19874 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
19875 <div class="warn-slow hidden" id="warn-slow">
19876 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.
19877 </div>
19878 <div class="err-panel hidden" id="err-panel">
19879 <strong>Analysis failed</strong>
19880 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
19881 </div>
19882 <div class="actions hidden" id="actions">
19883 <a href="/scan" class="btn-primary">Try Again</a>
19884 <a href="/view-reports" class="btn-outline">View Reports</a>
19885 </div>
19886 </div>
19887 </div>
19888 <script nonce="{{ csp_nonce }}">
19889 (function() {
19890 var WAIT_ID = {{ wait_id_json|safe }};
19891 var startTime = Date.now();
19892 var pollInterval = 1500;
19893 var retries = 0;
19894 var maxRetries = 5;
19895 var warnShown = false;
19896
19897 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();}
19898
19899 function elapsed() {
19900 return Math.floor((Date.now() - startTime) / 1000);
19901 }
19902
19903 function updateElapsed() {
19904 var s = elapsed();
19905 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
19906 }
19907
19908 function setPhase(txt) {
19909 document.getElementById('phase').textContent = txt;
19910 }
19911
19912 var elapsedTimer = setInterval(updateElapsed, 1000);
19913
19914 function poll() {
19915 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
19916 .then(function(r) {
19917 if (!r.ok) throw new Error('HTTP ' + r.status);
19918 return r.json();
19919 })
19920 .then(function(data) {
19921 retries = 0;
19922 if (data.state === 'complete') {
19923 clearInterval(elapsedTimer);
19924 setPhase('Done');
19925 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
19926 } else if (data.state === 'failed') {
19927 clearInterval(elapsedTimer);
19928 setPhase('Failed');
19929 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
19930 document.getElementById('err-panel').classList.remove('hidden');
19931 document.getElementById('actions').classList.remove('hidden');
19932 } else {
19933 // still running
19934 var s = elapsed();
19935 if (s > 90 && !warnShown) {
19936 warnShown = true;
19937 document.getElementById('warn-slow').classList.remove('hidden');
19938 }
19939 setPhase(data.phase || 'Running');
19940 var fd = data.files_done || 0, ft = data.files_total || 0;
19941 if (ft > 0) {
19942 var card = document.getElementById('files-card');
19943 if (card) card.classList.remove('hidden');
19944 var fp = document.getElementById('files-progress');
19945 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
19946 }
19947 setTimeout(poll, pollInterval);
19948 }
19949 })
19950 .catch(function(err) {
19951 retries++;
19952 if (retries >= maxRetries) {
19953 clearInterval(elapsedTimer);
19954 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
19955 document.getElementById('err-panel').classList.remove('hidden');
19956 document.getElementById('actions').classList.remove('hidden');
19957 } else {
19958 // exponential back-off capped at 8s
19959 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
19960 }
19961 });
19962 }
19963
19964 setTimeout(poll, pollInterval);
19965
19966 // If the browser restores this page from bfcache (Back after viewing results),
19967 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
19968 window.addEventListener("pageshow", function(e) {
19969 if (e.persisted) { setTimeout(poll, 200); }
19970 });
19971 })();
19972 </script>
19973 <footer class="site-footer">
19974 local code analysis - metrics, history and reports
19975 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19976 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19977 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19978 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19979 · <a href="/api-docs" rel="noopener">REST API</a>
19980 </footer>
19981 <script nonce="{{ csp_nonce }}">
19982 (function(){
19983 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
19984 if(s==="dark")b.classList.add("dark-theme");
19985 var tt=document.getElementById("theme-toggle");
19986 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
19987 })();
19988 (function spawnCodeParticles(){
19989 var c=document.getElementById('code-particles');if(!c)return;
19990 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'];
19991 for(var i=0;i<32;i++){(function(idx){
19992 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
19993 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
19994 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
19995 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
19996 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
19997 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
19998 c.appendChild(el);
19999 })(i);}
20000 })();
20001 (function randomizeWatermarks(){
20002 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20003 var placed=[];
20004 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;}
20005 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];}
20006 var half=Math.floor(wms.length/2);
20007 wms.forEach(function(img,i){
20008 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
20009 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
20010 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
20011 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
20012 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
20013 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
20014 });
20015 })();
20016 </script>
20017 <script nonce="{{ csp_nonce }}">
20018 (function(){
20019 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'}];
20020 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);});}
20021 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20022 function init(){
20023 var btn=document.getElementById('settings-btn');if(!btn)return;
20024 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20025 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>';
20026 document.body.appendChild(m);
20027 var g=document.getElementById('scheme-grid');
20028 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);});
20029 var cl=document.getElementById('settings-close');
20030 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);
20031 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');});
20032 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20033 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20034 }
20035 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20036 }());
20037 </script>
20038 <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=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>
20039</body>
20040</html>
20041"##,
20042 ext = "html"
20043)]
20044struct ScanWaitTemplate {
20045 version: &'static str,
20046 wait_id_json: String,
20047 project_path: String,
20048 csp_nonce: String,
20049}
20050
20051#[derive(Template)]
20052#[template(
20053 source = r##"
20054<!doctype html>
20055<html lang="en">
20056<head>
20057 <meta charset="utf-8">
20058 <meta name="viewport" content="width=device-width, initial-scale=1">
20059 <title>OxideSLOC | Error</title>
20060 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20061 <style nonce="{{ csp_nonce }}">
20062 :root {
20063 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20064 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20065 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
20066 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20067 }
20068 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
20069 *{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;}
20070 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20071 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20072 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
20073 .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);}
20074 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20075 .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));}
20076 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20077 .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;}
20078 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20079 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20080 @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; } }
20081 .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;}
20082 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20083 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20084 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20085 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20086 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20087 .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;}
20088 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20089 .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);}
20090 .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;}
20091 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20092 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20093 .settings-modal-body{padding:14px 16px 16px;}
20094 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20095 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20096 .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;}
20097 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20098 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20099 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20100 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20101 .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;}
20102 .tz-select:focus{border-color:var(--oxide);}
20103 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20104 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
20105 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20106 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20107 .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;}
20108 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
20109 .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);}
20110 .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;}
20111 .btn-secondary:hover{background:var(--line);}
20112 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
20113 .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;}
20114 .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;}
20115 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
20116 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
20117 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
20118 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
20119 .bug-report-panel.open{display:flex;}
20120 .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;}
20121 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
20122 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
20123 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
20124 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
20125 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
20126 .br-network-badge.online .br-net-dot{background:#2a6846;}
20127 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
20128 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
20129 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
20130 .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;}
20131 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
20132 .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;}
20133 .btn-sm:hover{background:var(--line);}
20134 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
20135 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
20136 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
20137 .bug-report-hint a:hover{text-decoration:underline;}
20138 .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;}
20139 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
20140 .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;}
20141 .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;}
20142 .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;}
20143 @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));}}
20144 .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;}
20145 </style>
20146</head>
20147<body>
20148 <div class="background-watermarks" aria-hidden="true">
20149 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20150 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20151 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20152 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20153 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20154 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20155 </div>
20156 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20157 <div class="top-nav">
20158 <div class="top-nav-inner">
20159 <a class="brand" href="/">
20160 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20161 <div class="brand-copy">
20162 <div class="brand-title">OxideSLOC</div>
20163 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20164 </div>
20165 </a>
20166 <div class="nav-right">
20167 <a class="nav-pill" href="/">Home</a>
20168 <div class="nav-dropdown">
20169 <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>
20170 <div class="nav-dropdown-menu">
20171 <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>
20172 </div>
20173 </div>
20174 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20175 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20176 <div class="nav-dropdown">
20177 <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>
20178 <div class="nav-dropdown-menu">
20179 <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>
20180 </div>
20181 </div>
20182 <div class="server-status-wrap" id="server-status-wrap">
20183 <div class="nav-pill server-online-pill" id="server-status-pill">
20184 <span class="status-dot" id="status-dot"></span>
20185 <span id="server-status-label">Server</span>
20186 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20187 </div>
20188 <div class="server-status-tip">
20189 OxideSLOC is running — accessible on your network.
20190 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20191 </div>
20192 </div>
20193 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20194 <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>
20195 </button>
20196 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20197 <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>
20198 <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>
20199 </button>
20200 </div>
20201 </div>
20202 </div>
20203
20204 <div class="page">
20205 <div class="panel">
20206 <h1>Error</h1>
20207 <div class="error-box" id="error-msg-text">{{ message }}</div>
20208 <div id="br-meta" hidden
20209 data-version="{{ version }}"
20210 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
20211 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
20212 <div class="actions">
20213 <a class="btn-primary" href="/scan">Back to setup</a>
20214 {% if let Some(report_url) = last_report_url %}
20215 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
20216 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
20217 {% else %}
20218 <a class="btn-secondary" href="/view-reports">View Reports</a>
20219 {% endif %}
20220 </div>
20221 <div class="bug-report-section" id="bug-report-section">
20222 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
20223 <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>
20224 Generate Bug Report
20225 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
20226 </button>
20227 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
20228 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
20229 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
20230 <div class="bug-report-btns">
20231 <button type="button" class="btn-sm" id="bug-report-copy">
20232 <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>
20233 Copy to clipboard
20234 </button>
20235 <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;">
20236 <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>
20237 Open GitHub Issue
20238 </a>
20239 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
20240 <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>
20241 Save as file
20242 </button>
20243 </div>
20244 <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>
20245 <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>
20246 </div>
20247 </div>
20248 </div>
20249 </div>
20250 <footer class="site-footer">
20251 oxide-sloc v{{ version }} — local code metrics workbench ·
20252 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20253 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20254 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20255 · <a href="/api-docs" rel="noopener">REST API</a>
20256 </footer>
20257 <script nonce="{{ csp_nonce }}">(function(){
20258 var meta=document.getElementById('br-meta');
20259 var pre=document.getElementById('bug-report-pre');
20260 var copyBtn=document.getElementById('bug-report-copy');
20261 var trigger=document.getElementById('bug-report-trigger');
20262 var panel=document.getElementById('bug-report-panel');
20263 var networkBadge=document.getElementById('br-network-badge');
20264 var networkLabel=document.getElementById('br-network-label');
20265 var ghLink=document.getElementById('bug-report-github-link');
20266 var saveBtn=document.getElementById('bug-report-save');
20267 var hintOnline=document.getElementById('br-hint-online');
20268 var hintOffline=document.getElementById('br-hint-offline');
20269 if(!meta||!pre)return;
20270 var ver=meta.getAttribute('data-version')||'';
20271 var runId=meta.getAttribute('data-run-id')||'';
20272 var code=meta.getAttribute('data-error-code')||'';
20273 var msgEl=document.getElementById('error-msg-text');
20274 var msg=msgEl?msgEl.textContent.trim():'';
20275 function getBrowser(){
20276 var ua=navigator.userAgent;
20277 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
20278 if(!m)return 'Unknown browser';
20279 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
20280 return n+' '+m[2];
20281 }
20282 var lines=['oxide-sloc Bug Report','==============================',''];
20283 lines.push('App version: v'+ver);
20284 if(code)lines.push('HTTP status: '+code);
20285 if(runId)lines.push('Run ID: '+runId);
20286 lines.push('Page: '+window.location.pathname+(window.location.search||''));
20287 lines.push('Timestamp: '+new Date().toISOString());
20288 lines.push('Browser: '+getBrowser());
20289 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
20290 lines.push('');
20291 lines.push('Error message:');
20292 lines.push(msg);
20293 lines.push('');
20294 lines.push('Steps to reproduce:');
20295 lines.push(' 1. ');
20296 lines.push('');
20297 lines.push('Expected behavior:');
20298 lines.push(' ');
20299 pre.textContent=lines.join('\n');
20300 function applyNetwork(online){
20301 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
20302 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
20303 if(ghLink){
20304 if(online){
20305 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
20306 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
20307 }
20308 ghLink.style.display=online?'inline-flex':'none';
20309 }
20310 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
20311 if(hintOnline)hintOnline.style.display=online?'block':'none';
20312 if(hintOffline)hintOffline.style.display=online?'none':'block';
20313 }
20314 applyNetwork(navigator.onLine);
20315 var probed=false;
20316 function probeNetwork(){
20317 if(probed)return;probed=true;
20318 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
20319 var probeIdx=0;
20320 function tryNext(){
20321 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
20322 var u=probeUrls[probeIdx++];
20323 var c2=new AbortController();
20324 var t2=setTimeout(function(){c2.abort();},4000);
20325 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
20326 .then(function(){clearTimeout(t2);applyNetwork(true);})
20327 .catch(function(){clearTimeout(t2);tryNext();});
20328 }
20329 tryNext();
20330 }
20331 if(trigger&&panel){
20332 trigger.addEventListener('click',function(){
20333 var open=panel.classList.toggle('open');
20334 trigger.classList.toggle('open',open);
20335 trigger.setAttribute('aria-expanded',open?'true':'false');
20336 if(open)probeNetwork();
20337 });
20338 }
20339 if(copyBtn){
20340 copyBtn.addEventListener('click',function(){
20341 var txt=pre.textContent;
20342 if(navigator.clipboard&&navigator.clipboard.writeText){
20343 navigator.clipboard.writeText(txt).then(function(){
20344 copyBtn.textContent='✓ Copied!';
20345 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);
20346 });
20347 }else{
20348 var ta=document.createElement('textarea');
20349 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
20350 document.body.appendChild(ta);ta.select();
20351 try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
20352 document.body.removeChild(ta);
20353 }
20354 });
20355 }
20356 if(saveBtn){
20357 saveBtn.addEventListener('click',function(){
20358 var txt=pre.textContent;
20359 var blob=new Blob([txt],{type:'text/plain'});
20360 var url=URL.createObjectURL(blob);
20361 var a=document.createElement('a');
20362 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
20363 document.body.appendChild(a);a.click();
20364 document.body.removeChild(a);URL.revokeObjectURL(url);
20365 });
20366 }
20367 })();</script>
20368 <script nonce="{{ csp_nonce }}">
20369 (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");});})();
20370 (function spawnCodeParticles() {
20371 var container = document.getElementById('code-particles');
20372 if (!container) return;
20373 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'];
20374 for (var i = 0; i < 38; i++) {
20375 (function(idx) {
20376 var el = document.createElement('span');
20377 el.className = 'code-particle';
20378 el.textContent = snippets[idx % snippets.length];
20379 var left = Math.random() * 94 + 2;
20380 var top = Math.random() * 88 + 6;
20381 var dur = (Math.random() * 10 + 9).toFixed(1);
20382 var delay = (Math.random() * 18).toFixed(1);
20383 var rot = (Math.random() * 26 - 13).toFixed(1);
20384 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20385 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';
20386 container.appendChild(el);
20387 })(i);
20388 }
20389 })();
20390 (function randomizeWatermarks() {
20391 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20392 var placed = [];
20393 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; }
20394 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]; }
20395 var half = Math.floor(wms.length/2);
20396 wms.forEach(function(img, i) {
20397 var pos = pick(i < half);
20398 var w = Math.floor(Math.random()*60+80);
20399 var rot = (Math.random()*40-20).toFixed(1);
20400 var op = (Math.random()*0.08+0.05).toFixed(2);
20401 var animDur = (Math.random()*6+5).toFixed(1);
20402 var animDelay = (Math.random()*10).toFixed(1);
20403 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';
20404 });
20405 })();
20406 </script>
20407 <script nonce="{{ csp_nonce }}">
20408 (function(){
20409 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'}];
20410 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);});}
20411 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20412 function init(){
20413 var btn=document.getElementById('settings-btn');if(!btn)return;
20414 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20415 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>';
20416 document.body.appendChild(m);
20417 var g=document.getElementById('scheme-grid');
20418 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);});
20419 var cl=document.getElementById('settings-close');
20420 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);
20421 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');});
20422 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20423 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20424 }
20425 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20426 }());
20427 </script>
20428 <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=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>
20429</body>
20430</html>
20431"##,
20432 ext = "html"
20433)]
20434struct ErrorTemplate {
20435 message: String,
20436 last_report_url: Option<String>,
20438 last_report_label: Option<String>,
20440 run_id: Option<String>,
20442 error_code: Option<u16>,
20444 csp_nonce: String,
20445 version: &'static str,
20446}
20447
20448#[derive(Template)]
20451#[template(
20452 source = r##"
20453<!doctype html>
20454<html lang="en">
20455<head>
20456 <meta charset="utf-8">
20457 <meta name="viewport" content="width=device-width, initial-scale=1">
20458 <title>OxideSLOC | Locate Report</title>
20459 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20460 <style nonce="{{ csp_nonce }}">
20461 :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);}
20462 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
20463 *{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;}
20464 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20465 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20466 .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);}
20467 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20468 .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));}
20469 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20470 .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;}
20471 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20472 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20473 @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;}}
20474 .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;}
20475 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20476 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20477 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20478 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20479 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
20480 .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;}
20481 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20482 .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);}
20483 .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;}
20484 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20485 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20486 .settings-modal-body{padding:14px 16px 16px;}
20487 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20488 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20489 .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;}
20490 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20491 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20492 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20493 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20494 .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;}
20495 .tz-select:focus{border-color:var(--oxide);}
20496 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20497 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20498 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20499 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
20500 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
20501 .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;}
20502 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
20503 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20504 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20505 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20506 .locate-row{display:flex;gap:8px;align-items:stretch;}
20507 .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;}
20508 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20509 body.dark-theme .locate-input{background:var(--surface-2);}
20510 .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;}
20511 .warning-banner.show{display:flex;}
20512 .warning-banner svg{flex:0 0 auto;}
20513 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
20514 .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;}
20515 .error-inline.show{display:flex;}
20516 .error-inline svg{flex:0 0 auto;margin-top:2px;}
20517 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
20518 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
20519 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
20520 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
20521 .err-kv-p{margin:0 0 4px;}
20522 .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;}
20523 .success-inline.show{display:flex;}
20524 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20525 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
20526 .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;}
20527 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
20528 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
20529 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
20530 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
20531 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
20532 .fh-row:last-child{border-bottom:none;}
20533 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
20534 .fh-dir{font-weight:800;color:var(--text);}
20535 .fh-hl{color:var(--oxide);font-weight:700;}
20536 .fh-muted{color:var(--muted);}
20537 .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;}
20538 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
20539 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
20540 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
20541 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
20542 .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;}
20543 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
20544 .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;}
20545 .btn-secondary:hover{background:var(--line);}
20546 .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;}
20547 .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;}
20548 .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;}
20549 @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));}}
20550 .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;}
20551 .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;}
20552 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
20553 </style>
20554</head>
20555<body>
20556 <div class="background-watermarks" aria-hidden="true">
20557 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20558 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20559 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20560 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20561 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20562 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20563 </div>
20564 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20565 <div class="top-nav">
20566 <div class="top-nav-inner">
20567 <a class="brand" href="/">
20568 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20569 <div class="brand-copy">
20570 <div class="brand-title">OxideSLOC</div>
20571 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20572 </div>
20573 </a>
20574 <div class="nav-right">
20575 <a class="nav-pill" href="/">Home</a>
20576 <div class="nav-dropdown">
20577 <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>
20578 <div class="nav-dropdown-menu">
20579 <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>
20580 </div>
20581 </div>
20582 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20583 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20584 <div class="nav-dropdown">
20585 <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>
20586 <div class="nav-dropdown-menu">
20587 <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>
20588 </div>
20589 </div>
20590 <div class="server-status-wrap" id="server-status-wrap">
20591 <div class="nav-pill server-online-pill" id="server-status-pill">
20592 <span class="status-dot" id="status-dot"></span>
20593 <span id="server-status-label">Server</span>
20594 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20595 </div>
20596 <div class="server-status-tip">
20597 OxideSLOC is running — accessible on your network.
20598 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20599 </div>
20600 </div>
20601 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20602 <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>
20603 </button>
20604 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20605 <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>
20606 <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>
20607 </button>
20608 </div>
20609 </div>
20610 </div>
20611
20612 <div class="page">
20613 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
20614 <div class="panel">
20615 <h1>Report File Not Found</h1>
20616 <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>
20617 <div class="field-label">Missing file</div>
20618 <div class="filename-chip">
20619 <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>
20620 {{ expected_filename }}
20621 </div>
20622 <div class="locate-section">
20623 <h2>Locate Scan Output Folder</h2>
20624 <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>
20625 <p>OxideSLOC will find the correct files inside automatically.</p>
20626 <div class="locate-row">
20627 <input type="text" id="locate-file-input"
20628 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
20629 class="locate-input" autocomplete="off" spellcheck="false">
20630 {% if !server_mode %}
20631 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
20632 {% endif %}
20633 </div>
20634 <div class="warning-banner" id="filename-warning">
20635 <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>
20636 <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>
20637 </div>
20638 <div class="error-inline" id="locate-error">
20639 <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>
20640 <span id="locate-error-text"></span>
20641 </div>
20642 <div class="success-inline" id="locate-success">
20643 <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>
20644 <span>Scan restored — loading report…</span>
20645 </div>
20646 <div class="btn-row">
20647 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
20648 <a class="btn-secondary" href="/view-reports">View Reports</a>
20649 </div>
20650 <div class="folder-hint-shell">
20651 <div class="folder-hint-hdr">
20652 <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>
20653 Expected Folder Structure — Select the Top-Level Folder
20654 </div>
20655 <div class="folder-hint-body">
20656 <div class="fh-row">
20657 <span class="fh-tog">►</span>
20658 <span class="fh-dir">project_20260601-0029-…/</span>
20659 <span class="fh-badge">← select this</span>
20660 </div>
20661 <div class="fh-row fh-i1">
20662 <span class="fh-tog">►</span>
20663 <span class="fh-dir">html/</span>
20664 </div>
20665 <div class="fh-row fh-i2">
20666 <span class="fh-bul">•</span>
20667 <span class="fh-hl">{{ expected_filename }}</span>
20668 </div>
20669 <div class="fh-row fh-i1">
20670 <span class="fh-tog">►</span>
20671 <span class="fh-dir">json/</span>
20672 </div>
20673 <div class="fh-row fh-i2">
20674 <span class="fh-bul">•</span>
20675 <span class="fh-muted">result_*.json</span>
20676 </div>
20677 <div class="fh-row fh-i1">
20678 <span class="fh-tog">►</span>
20679 <span class="fh-dir">pdf/</span>
20680 </div>
20681 <div class="fh-row fh-i2">
20682 <span class="fh-bul">•</span>
20683 <span class="fh-muted">report_*.pdf</span>
20684 </div>
20685 <div class="fh-row fh-i1">
20686 <span class="fh-tog">►</span>
20687 <span class="fh-dir">excel/</span>
20688 </div>
20689 <div class="fh-row fh-i2">
20690 <span class="fh-bul">•</span>
20691 <span class="fh-muted">report_*.csv report_*.xlsx</span>
20692 </div>
20693 </div>
20694 </div>
20695 </div>
20696 </div>
20697 </div>
20698 <footer class="site-footer">
20699 oxide-sloc v{{ version }} — local code metrics workbench ·
20700 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20701 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20702 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20703 · <a href="/api-docs" rel="noopener">REST API</a>
20704 </footer>
20705 <script nonce="{{ csp_nonce }}">(function(){
20706 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
20707 if(s==="dark")b.classList.add("dark-theme");
20708 document.getElementById("theme-toggle").addEventListener("click",function(){
20709 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
20710 });
20711 })();</script>
20712 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
20713 var c=document.getElementById('code-particles');if(!c)return;
20714 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'];
20715 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);}
20716 })();
20717 (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>
20718 <script nonce="{{ csp_nonce }}">(function(){
20719 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'}];
20720 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);});}
20721 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20722 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');});}
20723 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20724 }());</script>
20725 <script nonce="{{ csp_nonce }}">(function(){
20726 var meta=document.getElementById('locate-meta');
20727 var inp=document.getElementById('locate-file-input');
20728 var browseBtn=document.getElementById('browse-locate-btn');
20729 var submitBtn=document.getElementById('locate-submit-btn');
20730 var warning=document.getElementById('filename-warning');
20731 var errBox=document.getElementById('locate-error');
20732 var errText=document.getElementById('locate-error-text');
20733 var okBox=document.getElementById('locate-success');
20734 var expected=meta?meta.getAttribute('data-expected'):'';
20735 var runId=meta?meta.getAttribute('data-run-id'):'';
20736 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
20737 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
20738 function showErr(msg){
20739 if(errText){
20740 errText.innerHTML='';
20741 var lines=msg.split('\n');
20742 var hasPairs=lines.some(function(l){return / : /.test(l);});
20743 if(!hasPairs){errText.textContent=msg;}
20744 else{
20745 var frag=document.createDocumentFragment();var tbl=null;
20746 lines.forEach(function(line){
20747 var m=line.match(/^(.*?) : (.*)$/);
20748 if(m){
20749 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
20750 var tr=document.createElement('tr');
20751 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
20752 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
20753 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
20754 } else {
20755 tbl=null;
20756 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
20757 }
20758 });
20759 errText.appendChild(frag);
20760 }
20761 }
20762 if(errBox)errBox.classList.add('show');
20763 if(okBox)okBox.classList.remove('show');
20764 }
20765 function clearErr(){
20766 if(errBox)errBox.classList.remove('show');
20767 if(okBox)okBox.classList.remove('show');
20768 }
20769 function validate(){
20770 var val=inp?inp.value.trim():'';
20771 clearErr();
20772 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
20773 if(submitBtn)submitBtn.disabled=false;
20774 if(warning){
20775 var name=basename(val);
20776 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
20777 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
20778 else warning.classList.remove('show');
20779 }
20780 }
20781 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
20782 if(browseBtn){
20783 browseBtn.addEventListener('click',function(){
20784 browseBtn.disabled=true;browseBtn.textContent='...';
20785 fetch('/pick-directory')
20786 .then(function(r){return r.ok?r.json():{cancelled:true};})
20787 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
20788 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
20789 });
20790 }
20791 if(submitBtn){
20792 submitBtn.addEventListener('click',function(){
20793 var folder=inp?inp.value.trim():'';
20794 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
20795 clearErr();
20796 submitBtn.disabled=true;submitBtn.textContent='Restoring…';
20797 var body=new URLSearchParams();
20798 body.set('file_path',folder);
20799 body.set('redirect_url',redirectUrl);
20800 body.set('expected_run_id',runId);
20801 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
20802 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
20803 .then(function(d){
20804 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20805 if(d&&d.ok){
20806 if(okBox)okBox.classList.add('show');
20807 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
20808 } else {
20809 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
20810 }
20811 })
20812 .catch(function(e){
20813 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20814 showErr('Network error: '+String(e));
20815 });
20816 });
20817 }
20818 })();</script>
20819 <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>
20820</body>
20821</html>
20822"##,
20823 ext = "html"
20824)]
20825struct LocateFileTemplate {
20826 run_id: String,
20827 artifact_type: String,
20828 expected_filename: String,
20829 server_mode: bool,
20830 csp_nonce: String,
20831 version: &'static str,
20832}
20833
20834#[derive(Template)]
20837#[template(
20838 source = r##"
20839<!doctype html>
20840<html lang="en">
20841<head>
20842 <meta charset="utf-8">
20843 <meta name="viewport" content="width=device-width, initial-scale=1">
20844 <title>OxideSLOC | Locate Scan Files</title>
20845 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20846 <style nonce="{{ csp_nonce }}">
20847 :root {
20848 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20849 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20850 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
20851 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20852 }
20853 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
20854 *{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;}
20855 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20856 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20857 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
20858 .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);}
20859 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20860 .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));}
20861 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20862 .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;}
20863 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20864 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20865 @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;}}
20866 .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;}
20867 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20868 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20869 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20870 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20871 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20872 .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;}
20873 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20874 .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);}
20875 .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;}
20876 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20877 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20878 .settings-modal-body{padding:14px 16px 16px;}
20879 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20880 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20881 .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;}
20882 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20883 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20884 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20885 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20886 .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;}
20887 .tz-select:focus{border-color:var(--oxide);}
20888 .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20889 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20890 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20891 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
20892 .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;}
20893 .error-box.hidden{display:none;}
20894 .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;}
20895 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20896 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
20897 .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;}
20898 .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;}
20899 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
20900 .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;}
20901 .btn-secondary:hover{background:var(--line);}
20902 .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;}
20903 .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;}
20904 .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;}
20905 @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));}}
20906 .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;}
20907 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20908 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20909 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20910 .relocate-row{display:flex;gap:8px;align-items:stretch;}
20911 .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;}
20912 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20913 body.dark-theme .relocate-input{background:var(--surface-2);}
20914 </style>
20915</head>
20916<body>
20917 <div class="background-watermarks" aria-hidden="true">
20918 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20919 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20920 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20921 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20922 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20923 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20924 </div>
20925 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20926 <div class="top-nav">
20927 <div class="top-nav-inner">
20928 <a class="brand" href="/">
20929 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20930 <div class="brand-copy">
20931 <div class="brand-title">OxideSLOC</div>
20932 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20933 </div>
20934 </a>
20935 <div class="nav-right">
20936 <a class="nav-pill" href="/">Home</a>
20937 <div class="nav-dropdown">
20938 <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>
20939 <div class="nav-dropdown-menu">
20940 <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>
20941 </div>
20942 </div>
20943 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
20944 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20945 <div class="nav-dropdown">
20946 <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>
20947 <div class="nav-dropdown-menu">
20948 <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>
20949 </div>
20950 </div>
20951 <div class="server-status-wrap" id="server-status-wrap">
20952 <div class="nav-pill server-online-pill" id="server-status-pill">
20953 <span class="status-dot" id="status-dot"></span>
20954 <span id="server-status-label">Server</span>
20955 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20956 </div>
20957 <div class="server-status-tip">
20958 OxideSLOC is running — accessible on your network.
20959 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20960 </div>
20961 </div>
20962 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20963 <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>
20964 </button>
20965 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20966 <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>
20967 <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>
20968 </button>
20969 </div>
20970 </div>
20971 </div>
20972
20973 <div class="page">
20974 <div class="panel">
20975 <h1>Scan Files Moved</h1>
20976 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
20977 <div class="error-box" id="relocate-error-box">{{ message }}</div>
20978 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
20979 <div class="relocate-section">
20980 <h2>Locate Scan Output</h2>
20981 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
20982 <div class="relocate-row">
20983 <input type="text" id="relocate-folder" name="folder_path"
20984 value="{{ folder_hint }}"
20985 placeholder="Path to folder containing scan output..."
20986 class="relocate-input" autocomplete="off" spellcheck="false">
20987 {% if !server_mode %}
20988 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
20989 {% endif %}
20990 </div>
20991 <div style="margin-top:12px;">
20992 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
20993 </div>
20994 </div>
20995 <div class="actions">
20996 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
20997 <a class="btn-secondary" href="/view-reports">View Reports</a>
20998 </div>
20999 </div>
21000 </div>
21001 <footer class="site-footer">
21002 oxide-sloc v{{ version }} — local code metrics workbench ·
21003 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21004 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21005 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21006 · <a href="/api-docs" rel="noopener">REST API</a>
21007 </footer>
21008 <script nonce="{{ csp_nonce }}">
21009 (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");});})();
21010 (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);}})();
21011 (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;});})();
21012 </script>
21013 <script nonce="{{ csp_nonce }}">
21014 (function(){
21015 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'}];
21016 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);});}
21017 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21018 function init(){
21019 var btn=document.getElementById('settings-btn');if(!btn)return;
21020 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21021 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>';
21022 document.body.appendChild(m);
21023 var g=document.getElementById('scheme-grid');
21024 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);});
21025 var cl=document.getElementById('settings-close');
21026 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);
21027 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');});
21028 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21029 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21030 }
21031 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21032 }());
21033 (function(){
21034 var browseBtn=document.getElementById('browse-relocate-btn');
21035 if(browseBtn){
21036 browseBtn.addEventListener('click',function(){
21037 browseBtn.disabled=true;browseBtn.textContent='...';
21038 var inp=document.getElementById('relocate-folder');
21039 var hint=inp?inp.value:'';
21040 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
21041 .then(function(r){return r.ok?r.json():{cancelled:true};})
21042 .then(function(d){
21043 browseBtn.disabled=false;browseBtn.textContent='Browse…';
21044 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
21045 })
21046 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
21047 });
21048 }
21049 var restoreBtn=document.getElementById('restore-btn');
21050 var errBox=document.getElementById('relocate-error-box');
21051 var okBox=document.getElementById('relocate-success-box');
21052 if(restoreBtn){
21053 restoreBtn.addEventListener('click',function(){
21054 var inp=document.getElementById('relocate-folder');
21055 var folder=inp?inp.value.trim():'';
21056 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
21057 restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
21058 var body=new URLSearchParams();
21059 body.set('run_id','{{ run_id }}');
21060 body.set('redirect_url','{{ redirect_url }}');
21061 body.set('folder_path',folder);
21062 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
21063 .then(function(r){return r.json();})
21064 .then(function(d){
21065 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
21066 if(d&&d.ok){
21067 if(errBox)errBox.classList.add('hidden');
21068 if(okBox){okBox.style.display='block';}
21069 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
21070 } else {
21071 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
21072 }
21073 })
21074 .catch(function(e){
21075 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
21076 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
21077 });
21078 });
21079 }
21080 }());
21081 </script>
21082 <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=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>
21083</body>
21084</html>
21085"##,
21086 ext = "html"
21087)]
21088struct RelocateScanTemplate {
21089 message: String,
21090 run_id: String,
21091 folder_hint: String,
21092 redirect_url: String,
21093 server_mode: bool,
21094 csp_nonce: String,
21095 version: &'static str,
21096}
21097
21098#[derive(Template)]
21101#[template(
21102 source = r##"
21103<!doctype html>
21104<html lang="en">
21105<head>
21106 <meta charset="utf-8">
21107 <meta name="viewport" content="width=device-width, initial-scale=1">
21108 <title>OxideSLOC | View Reports</title>
21109 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21110 <style nonce="{{ csp_nonce }}">
21111 :root {
21112 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
21113 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21114 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21115 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21116 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
21117 }
21118 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; }
21119 *{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;}
21120 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21121 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21122 .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);}
21123 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21124 .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));}
21125 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21126 .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;}
21127 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21128 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21129 @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; } }
21130 .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;}
21131 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21132 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
21133 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21134 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21135 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21136 .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;}
21137 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21138 .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);}
21139 .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;}
21140 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21141 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21142 .settings-modal-body{padding:14px 16px 16px;}
21143 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21144 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21145 .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;}
21146 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21147 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21148 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21149 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21150 .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;}
21151 .tz-select:focus{border-color:var(--oxide);}
21152 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
21153 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21154 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
21155 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
21156 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21157 .panel-meta{font-size:13px;color:var(--muted);}
21158 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
21159 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
21160 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
21161 .per-page-label{font-size:13px;color:var(--muted);}
21162 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;}
21163 .filter-input{min-width:180px;cursor:text;}
21164 .table-wrap{width:100%;overflow-x:auto;}
21165 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
21166 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;}
21167 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
21168 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
21169 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
21170 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
21171 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
21172 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21173 tr:last-child td{border-bottom:none;}
21174 tr:hover td{background:var(--surface-2);}
21175 .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);}
21176 .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);}
21177 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
21178 .metric-num{font-weight:700;color:var(--text);}
21179 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
21180 .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;}
21181 .btn:hover{background:var(--line);}
21182 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21183 .btn.primary:hover{opacity:.9;}
21184 .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;}
21185 .btn-back:hover{background:var(--line);}
21186 .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;}
21187 .export-btn:hover{background:var(--line);}
21188 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
21189 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
21190 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
21191 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
21192 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
21193 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
21194 .pagination-info{font-size:13px;color:var(--muted);}
21195 .pagination-btns{display:flex;gap:6px;}
21196 .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;}
21197 .pg-btn:hover:not(:disabled){background:var(--line);}
21198 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21199 .pg-btn:disabled{opacity:.35;cursor:default;}
21200 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
21201 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
21202 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
21203 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
21204 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
21205 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
21206 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
21207 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
21208 .stat-chip:hover .stat-chip-tip{opacity:1;}
21209 .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;}
21210 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21211 .site-footer a{color:var(--muted);}
21212 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
21213 .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%;}
21214 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
21215 .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;}
21216 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
21217 .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;}
21218 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
21219 .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;}
21220 .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;}
21221 .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;}
21222 @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));}}
21223 .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;}
21224 .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;}
21225 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
21226 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
21227 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
21228 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
21229 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
21230 .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;}
21231 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21232 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
21233 .watched-chip-rm:hover{color:var(--oxide);}
21234 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
21235 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
21236 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
21237 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
21238 .rpt-btn{min-width:58px;justify-content:center;}
21239 .flex-row{display:flex;align-items:center;gap:8px;}
21240 .report-cell{overflow:visible;white-space:normal;}
21241 #history-table col:nth-child(1){width:185px;}
21242 #history-table col:nth-child(2){width:220px;}
21243 #history-table col:nth-child(3){width:100px;}
21244 #history-table col:nth-child(4){width:72px;}
21245 #history-table col:nth-child(5){width:82px;}
21246 #history-table col:nth-child(6){width:82px;}
21247 #history-table col:nth-child(7){width:65px;}
21248 #history-table col:nth-child(8){width:90px;}
21249 #history-table col:nth-child(9){width:85px;}
21250 #history-table col:nth-child(10){width:115px;}
21251 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
21252 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
21253 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
21254 .submod-details summary::-webkit-details-marker{display:none;}
21255.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
21256 .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;}
21257 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
21258 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
21259 </style>
21260</head>
21261<body>
21262 <div class="background-watermarks" aria-hidden="true">
21263 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21264 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21265 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21266 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21267 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21268 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21269 </div>
21270 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21271 <div class="top-nav">
21272 <div class="top-nav-inner">
21273 <a class="brand" href="/">
21274 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21275 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
21276 </a>
21277 <div class="nav-right">
21278 <a class="nav-pill" href="/">Home</a>
21279 <div class="nav-dropdown">
21280 <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>
21281 <div class="nav-dropdown-menu">
21282 <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>
21283 </div>
21284 </div>
21285 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21286 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21287 <div class="nav-dropdown">
21288 <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>
21289 <div class="nav-dropdown-menu">
21290 <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>
21291 </div>
21292 </div>
21293 <div class="server-status-wrap" id="server-status-wrap">
21294 <div class="nav-pill server-online-pill" id="server-status-pill">
21295 <span class="status-dot" id="status-dot"></span>
21296 <span id="server-status-label">Server</span>
21297 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21298 </div>
21299 <div class="server-status-tip">
21300 OxideSLOC is running — accessible on your network.
21301 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21302 </div>
21303 </div>
21304 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21305 <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>
21306 </button>
21307 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21308 <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>
21309 <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>
21310 </button>
21311 </div>
21312 </div>
21313 </div>
21314
21315 <div class="page">
21316 {% if let Some(err) = browse_error %}
21317 <div class="toast-error">
21318 <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>
21319 {{ err }}
21320 </div>
21321 {% endif %}
21322 {% if linked_count > 0 %}
21323 <div class="toast-success">
21324 <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>
21325 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
21326 </div>
21327 {% endif %}
21328 <div class="watched-bar">
21329 <div class="watched-bar-left">
21330 <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>
21331 <span class="watched-label">Watched Folders</span>
21332 <div class="watched-chips">
21333 {% if server_mode %}
21334 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
21335 {% else %}
21336 {% for dir in watched_dirs %}
21337 <span class="watched-chip">
21338 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
21339 <form method="POST" action="/watched-dirs/remove" style="display:contents">
21340 <input type="hidden" name="folder_path" value="{{ dir }}">
21341 <input type="hidden" name="redirect_to" value="/view-reports">
21342 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
21343 </form>
21344 </span>
21345 {% endfor %}
21346 {% if watched_dirs.is_empty() %}
21347 <span class="watched-none">No folders watched — click Choose to add one</span>
21348 {% endif %}
21349 {% endif %}
21350 </div>
21351 </div>
21352 {% if !server_mode %}
21353 <div class="watched-bar-right">
21354 <button type="button" class="btn" id="add-watched-btn">
21355 <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>
21356 Choose
21357 </button>
21358 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
21359 <input type="hidden" name="redirect_to" value="/view-reports">
21360 <button type="submit" class="btn">↻ Refresh</button>
21361 </form>
21362 </div>
21363 {% endif %}
21364 </div>
21365 {% if total_scans > 0 %}
21366 <div class="summary-strip">
21367 <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>
21368 <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>
21369 <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>
21370 <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>
21371 </div>
21372 {% endif %}
21373
21374 <section class="panel">
21375 <div class="panel-header">
21376 <div>
21377 <h1>View Reports</h1>
21378 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
21379 {% 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 %}
21380 </div>
21381 <div class="flex-row">
21382 <button type="button" class="export-btn" id="export-csv-btn">
21383 <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>
21384 Export CSV
21385 </button>
21386 <button type="button" class="export-btn" id="export-xls-btn">
21387 <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>
21388 Export Excel
21389 </button>
21390 </div>
21391 </div>
21392
21393 {% if entries.is_empty() %}
21394 <div class="empty-state">
21395 <strong>No reports with viewable HTML yet</strong>
21396 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.
21397 </div>
21398 {% else %}
21399 <div class="filter-row">
21400 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
21401 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
21402 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
21403 </div>
21404 <div class="table-wrap">
21405 <table id="history-table">
21406 <colgroup>
21407 <col><col><col><col><col><col><col><col><col><col>
21408 </colgroup>
21409 <thead>
21410 <tr id="history-thead">
21411 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21412 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21413 <th>Run ID<div class="col-resize-handle"></div></th>
21414 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21415 <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>
21416 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21417 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21418 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21419 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21420 <th>Report<div class="col-resize-handle"></div></th>
21421 </tr>
21422 </thead>
21423 <tbody id="history-tbody">
21424 {% for entry in entries %}
21425 <tr class="history-row" data-run="{{ entry.run_id }}"
21426 data-timestamp="{{ entry.timestamp }}"
21427 data-project="{{ entry.project_label }}"
21428 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
21429 data-skipped="{{ entry.files_skipped }}"
21430 data-comments="{{ entry.comment_lines }}"
21431 data-blank="{{ entry.blank_lines }}"
21432 data-branch="{{ entry.git_branch }}"
21433 data-commit="{{ entry.git_commit }}"
21434 data-html-url="/runs/html/{{ entry.run_id }}">
21435 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
21436 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
21437 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
21438 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
21439 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
21440 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
21441 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
21442 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
21443 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip" title="{{ entry.git_commit }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
21444 <td class="report-cell">
21445 <div class="actions-cell">
21446 {% 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 %}
21447 {% 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 %}
21448 </div>
21449 {% if !entry.submodule_links.is_empty() %}
21450 <details class="submod-details">
21451 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
21452 <div class="submod-link-list">
21453 {% for sub in entry.submodule_links %}
21454 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
21455 {% endfor %}
21456 </div>
21457 </details>
21458 {% endif %}
21459 </td>
21460 </tr>
21461 {% endfor %}
21462 </tbody>
21463 </table>
21464 </div>
21465 <div class="pagination">
21466 <span class="pagination-info" id="pagination-info"></span>
21467 <div class="pagination-btns" id="pagination-btns"></div>
21468 <div class="flex-row">
21469 <span class="per-page-label">Show</span>
21470 <select class="per-page" id="per-page-sel">
21471 <option value="10">10 per page</option>
21472 <option value="25" selected>25 per page</option>
21473 <option value="50">50 per page</option>
21474 <option value="100">100 per page</option>
21475 </select>
21476 <span class="per-page-label" id="page-range-label"></span>
21477 </div>
21478 </div>
21479 {% endif %}
21480 </section>
21481 </div>
21482
21483 <footer class="site-footer">
21484 local code analysis - metrics, history and reports
21485 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21486 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21487 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21488 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21489 · <a href="/api-docs" rel="noopener">REST API</a>
21490 </footer>
21491
21492 <script nonce="{{ csp_nonce }}">
21493 (function () {
21494 // ── Theme ──────────────────────────────────────────────────────────────
21495 var storageKey = 'oxide-sloc-theme';
21496 var body = document.body;
21497 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21498 var toggle = document.getElementById('theme-toggle');
21499 if (toggle) toggle.addEventListener('click', function () {
21500 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21501 body.classList.toggle('dark-theme', next === 'dark');
21502 try { localStorage.setItem(storageKey, next); } catch(e) {}
21503 });
21504
21505 // ── State ─────────────────────────────────────────────────────────────
21506 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
21507 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
21508 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
21509
21510 // Aggregate stats from first (most recent) row
21511 if (allRows.length) {
21512 var first = allRows[0];
21513 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();}
21514 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>':'');}
21515 setChipVal('agg-code', first.dataset.code);
21516 setChipVal('agg-files', first.dataset.files);
21517 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
21518 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
21519 }
21520
21521 // ── Branch filter population ──────────────────────────────────────────
21522 (function() {
21523 var branches = {};
21524 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
21525 var sel = document.getElementById('branch-filter');
21526 if (sel) Object.keys(branches).sort().forEach(function(b) {
21527 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
21528 });
21529 })();
21530
21531 // ── Filter ────────────────────────────────────────────────────────────
21532 function getFilteredRows() {
21533 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
21534 var branch = ((document.getElementById('branch-filter') || {}).value || '');
21535 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
21536 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
21537 if (branch && (r.dataset.branch || '') !== branch) return false;
21538 return true;
21539 });
21540 }
21541
21542 // ── Pagination ────────────────────────────────────────────────────────
21543 function renderPage() {
21544 var filtered = getFilteredRows();
21545 var total = filtered.length;
21546 var totalPages = Math.max(1, Math.ceil(total / perPage));
21547 currentPage = Math.min(currentPage, totalPages);
21548 var start = (currentPage - 1) * perPage;
21549 var end = Math.min(start + perPage, total);
21550 var shown = {};
21551 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
21552 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
21553 r.style.display = shown[r.dataset.run] ? '' : 'none';
21554 });
21555 var rl = document.getElementById('page-range-label');
21556 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
21557 var info = document.getElementById('pagination-info');
21558 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
21559 var btns = document.getElementById('pagination-btns');
21560 if (!btns) return;
21561 btns.innerHTML = '';
21562 function makeBtn(lbl, pg, active, disabled) {
21563 var b = document.createElement('button');
21564 b.className = 'pg-btn' + (active ? ' active' : '');
21565 b.textContent = lbl; b.disabled = disabled;
21566 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
21567 return b;
21568 }
21569 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
21570 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
21571 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
21572 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
21573 }
21574
21575 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
21576 window.applyFilters = function() { currentPage = 1; renderPage(); };
21577
21578 // ── Sorting ───────────────────────────────────────────────────────────
21579 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
21580 function doSort(col, type, order) {
21581 var tbody = document.getElementById('history-tbody');
21582 if (!tbody) return;
21583 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21584 rows.sort(function(a, b) {
21585 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
21586 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
21587 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
21588 return va < vb ? 1 : va > vb ? -1 : 0;
21589 });
21590 rows.forEach(function(r) { tbody.appendChild(r); });
21591 currentPage = 1; renderPage();
21592 }
21593 sortHeaders.forEach(function(th) {
21594 th.addEventListener('click', function(e) {
21595 if (e.target.classList.contains('col-resize-handle')) return;
21596 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
21597 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
21598 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21599 th.classList.add('sort-' + sortOrder);
21600 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
21601 doSort(col, type, sortOrder);
21602 });
21603 });
21604
21605 // ── Column resize ─────────────────────────────────────────────────────
21606 (function() {
21607 var table = document.getElementById('history-table');
21608 if (!table) return;
21609 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
21610 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
21611 ths.forEach(function(th, i) {
21612 var handle = th.querySelector('.col-resize-handle');
21613 if (!handle || !cols[i]) return;
21614 var startX, startW;
21615 handle.addEventListener('mousedown', function(e) {
21616 e.stopPropagation(); e.preventDefault();
21617 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
21618 handle.classList.add('dragging');
21619 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
21620 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
21621 document.addEventListener('mousemove', onMove);
21622 document.addEventListener('mouseup', onUp);
21623 });
21624 });
21625 })();
21626
21627 // ── Reset view ────────────────────────────────────────────────────────
21628 window.resetView = function() {
21629 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
21630 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
21631 sortCol = null; sortOrder = 'asc';
21632 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21633 var tbody = document.getElementById('history-tbody');
21634 if (tbody) {
21635 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21636 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
21637 rows.forEach(function(r) { tbody.appendChild(r); });
21638 }
21639 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
21640 var table = document.getElementById('history-table');
21641 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
21642 currentPage = 1; renderPage();
21643 };
21644
21645 renderPage();
21646
21647 // ── Export helpers ────────────────────────────────────────────────────
21648 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
21649 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
21650 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);}
21651 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;');}
21652 function slocXlsx(fname,sheet,hdrs,rows){
21653 var enc=new TextEncoder();
21654 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;}
21655 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;}
21656 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
21657 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
21658 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21659 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;}
21660 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];}
21661 var rx='<row r="1">';
21662 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
21663 rx+='</row>';
21664 rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn),num=cell!==''&&cell!=null&&!isNaN(Number(cell))&&isFinite(Number(cell))&&/^[+\-]?\d/.test(String(cell));rx+=num?'<c r="'+ref+'"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});
21665 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
21666 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';
21667 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>';
21668 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
21669 var F={'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
21670 '_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>',
21671 '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>',
21672 '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>',
21673 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
21674 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'];
21675 var zparts=[],zcds=[],zoff=0,znf=0;
21676 order.forEach(function(name){
21677 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
21678 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]);
21679 var entry=new Uint8Array(lha.length+nb.length+sz);
21680 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
21681 zparts.push(entry);
21682 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));
21683 var cde=new Uint8Array(cda.length+nb.length);
21684 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
21685 zcds.push(cde);zoff+=entry.length;znf++;
21686 });
21687 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
21688 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]);
21689 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
21690 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
21691 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
21692 zout.set(new Uint8Array(ea),zpos);
21693 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
21694 }
21695
21696 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
21697 function getHistoryRows(){var r=[];document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){r.push([tr.getAttribute('data-timestamp')||'',tr.getAttribute('data-project')||'',tr.getAttribute('data-run')||'',tr.getAttribute('data-files')||'',tr.getAttribute('data-skipped')||'',tr.getAttribute('data-code')||'',tr.getAttribute('data-comments')||'',tr.getAttribute('data-blank')||'',tr.getAttribute('data-branch')||'',tr.getAttribute('data-commit')||'']);});return r;}
21698 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
21699 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
21700
21701 var csvBtn = document.getElementById('export-csv-btn');
21702 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
21703 var xlsBtn = document.getElementById('export-xls-btn');
21704 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
21705
21706 // ── Remaining CSP-safe event bindings ────────────────────────────────
21707 (function wireEvents() {
21708 var el;
21709 el = document.getElementById('reset-view-btn');
21710 if (el) el.addEventListener('click', window.resetView);
21711 el = document.getElementById('project-filter');
21712 if (el) el.addEventListener('input', window.applyFilters);
21713 el = document.getElementById('branch-filter');
21714 if (el) el.addEventListener('change', window.applyFilters);
21715 el = document.getElementById('per-page-sel');
21716 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
21717 el = document.getElementById('add-watched-btn');
21718 if (el) el.addEventListener('click', function() {
21719 fetch('/pick-directory?kind=reports')
21720 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
21721 .then(function(data) {
21722 if (!data.cancelled && data.selected_path) {
21723 var form = document.createElement('form');
21724 form.method = 'POST';
21725 form.action = '/watched-dirs/add';
21726 var ri = document.createElement('input');
21727 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
21728 var fi = document.createElement('input');
21729 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
21730 form.appendChild(ri); form.appendChild(fi);
21731 document.body.appendChild(form);
21732 form.submit();
21733 }
21734 })
21735 .catch(function(e) { alert('Could not open folder picker: ' + e); });
21736 });
21737 })();
21738
21739 (function randomizeWatermarks() {
21740 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21741 if (!wms.length) return;
21742 var placed = [];
21743 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;}
21744 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];}
21745 var half=Math.floor(wms.length/2);
21746 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;});
21747 })();
21748
21749 (function spawnCodeParticles() {
21750 var container = document.getElementById('code-particles');
21751 if (!container) return;
21752 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'];
21753 for (var i = 0; i < 38; i++) {
21754 (function(idx) {
21755 var el = document.createElement('span');
21756 el.className = 'code-particle';
21757 el.textContent = snippets[idx % snippets.length];
21758 var left = Math.random() * 94 + 2;
21759 var top = Math.random() * 88 + 6;
21760 var dur = (Math.random() * 10 + 9).toFixed(1);
21761 var delay = (Math.random() * 18).toFixed(1);
21762 var rot = (Math.random() * 26 - 13).toFixed(1);
21763 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21764 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';
21765 container.appendChild(el);
21766 })(i);
21767 }
21768 })();
21769 })();
21770 </script>
21771 <script nonce="{{ csp_nonce }}">
21772 (function(){
21773 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'}];
21774 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);});}
21775 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21776 function init(){
21777 var btn=document.getElementById('settings-btn');if(!btn)return;
21778 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21779 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>';
21780 document.body.appendChild(m);
21781 var g=document.getElementById('scheme-grid');
21782 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);});
21783 var cl=document.getElementById('settings-close');
21784 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);
21785 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');});
21786 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21787 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21788 }
21789 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21790 }());
21791 </script>
21792 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
21793</body>
21794</html>
21795"##,
21796 ext = "html"
21797)]
21798struct HistoryTemplate {
21799 version: &'static str,
21800 entries: Vec<HistoryEntryRow>,
21801 total_scans: usize,
21802 linked_count: usize,
21803 browse_error: Option<String>,
21804 watched_dirs: Vec<String>,
21805 csp_nonce: String,
21806 server_mode: bool,
21807}
21808
21809#[derive(Template)]
21812#[template(
21813 source = r##"
21814<!doctype html>
21815<html lang="en">
21816<head>
21817 <meta charset="utf-8">
21818 <meta name="viewport" content="width=device-width, initial-scale=1">
21819 <title>OxideSLOC | Compare Scans</title>
21820 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21821 <style nonce="{{ csp_nonce }}">
21822 :root {
21823 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
21824 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21825 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21826 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21827 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
21828 }
21829 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
21830 *{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;}
21831 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21832 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21833 .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);}
21834 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21835 .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));}
21836 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21837 .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;}
21838 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21839 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21840 @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; } }
21841 .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;}
21842 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21843 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
21844 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21845 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21846 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21847 .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;}
21848 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21849 .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);}
21850 .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;}
21851 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21852 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21853 .settings-modal-body{padding:14px 16px 16px;}
21854 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21855 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21856 .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;}
21857 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21858 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21859 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21860 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21861 .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;}
21862 .tz-select:focus{border-color:var(--oxide);}
21863 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
21864 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21865 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
21866 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
21867 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21868 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
21869 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
21870 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
21871 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
21872 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
21873 .per-page-label{font-size:13px;color:var(--muted);}
21874 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;}
21875 .filter-input{min-width:180px;cursor:text;}
21876 .table-wrap{width:100%;overflow-x:auto;}
21877 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
21878 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;}
21879 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
21880 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
21881 #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;}
21882 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
21883 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
21884 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
21885 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
21886 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
21887 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
21888 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
21889 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
21890 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
21891 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
21892 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
21893 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
21894 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21895 tr:last-child td{border-bottom:none;}
21896 tr.selected td{background:var(--sel-bg);}
21897 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
21898 tr:hover:not(.selected) td{background:var(--surface-2);}
21899 tr{cursor:pointer;}
21900 .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);}
21901 .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);}
21902 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
21903 .metric-num{font-weight:700;color:var(--text);}
21904 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
21905 .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;}
21906 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
21907 .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;}
21908 .btn:hover{background:var(--line);}
21909 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
21910 .btn.primary:hover{opacity:.9;}
21911 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
21912 .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;}
21913 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
21914 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
21915 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
21916 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
21917 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
21918 .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;}
21919 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21920 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
21921 .watched-chip-rm:hover{color:var(--oxide);}
21922 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
21923 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
21924 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
21925 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
21926 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
21927 .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;}
21928 .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;}
21929 .btn-back:hover{background:var(--line);}
21930 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
21931 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
21932 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
21933 .pagination-info{font-size:13px;color:var(--muted);}
21934 .pagination-btns{display:flex;gap:6px;}
21935 .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;}
21936 .pg-btn:hover:not(:disabled){background:var(--line);}
21937 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21938 .pg-btn:disabled{opacity:.35;cursor:default;}
21939 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
21940 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21941 .site-footer a{color:var(--muted);}
21942 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
21943 .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;}
21944 .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;}
21945 .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;}
21946 @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));}}
21947 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
21948 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
21949 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
21950 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
21951 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
21952 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
21953 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
21954 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
21955 .stat-chip:hover .stat-chip-tip{opacity:1;}
21956 .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;}
21957 .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;}
21958 .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%;}
21959 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
21960 .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;}
21961 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
21962 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
21963 .hidden{display:none!important;}
21964 .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%;}
21965 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
21966 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
21967 .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;}
21968 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
21969 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
21970 .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;}
21971 .scope-option:hover{background:var(--line);}
21972 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
21973 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
21974 .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;}
21975 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
21976 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
21977 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
21978 .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;}
21979 </style>
21980</head>
21981<body>
21982 <div class="background-watermarks" aria-hidden="true">
21983 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21984 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21985 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21986 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21987 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21988 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21989 </div>
21990 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21991 <div class="top-nav">
21992 <div class="top-nav-inner">
21993 <a class="brand" href="/">
21994 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21995 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
21996 </a>
21997 <div class="nav-right">
21998 <a class="nav-pill" href="/">Home</a>
21999 <div class="nav-dropdown">
22000 <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>
22001 <div class="nav-dropdown-menu">
22002 <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>
22003 </div>
22004 </div>
22005 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22006 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22007 <div class="nav-dropdown">
22008 <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>
22009 <div class="nav-dropdown-menu">
22010 <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>
22011 </div>
22012 </div>
22013 <div class="server-status-wrap" id="server-status-wrap">
22014 <div class="nav-pill server-online-pill" id="server-status-pill">
22015 <span class="status-dot" id="status-dot"></span>
22016 <span id="server-status-label">Server</span>
22017 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22018 </div>
22019 <div class="server-status-tip">
22020 OxideSLOC is running — accessible on your network.
22021 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22022 </div>
22023 </div>
22024 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22025 <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>
22026 </button>
22027 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22028 <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>
22029 <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>
22030 </button>
22031 </div>
22032 </div>
22033 </div>
22034
22035 <div class="page">
22036 <div class="watched-bar">
22037 <div class="watched-bar-left">
22038 <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>
22039 <span class="watched-label">Watched Folders</span>
22040 <div class="watched-chips">
22041 {% if server_mode %}
22042 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
22043 {% else %}
22044 {% for dir in watched_dirs %}
22045 <span class="watched-chip">
22046 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
22047 <form method="POST" action="/watched-dirs/remove" style="display:contents">
22048 <input type="hidden" name="folder_path" value="{{ dir }}">
22049 <input type="hidden" name="redirect_to" value="/compare-scans">
22050 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
22051 </form>
22052 </span>
22053 {% endfor %}
22054 {% if watched_dirs.is_empty() %}
22055 <span class="watched-none">No folders watched — click Choose to add one</span>
22056 {% endif %}
22057 {% endif %}
22058 </div>
22059 </div>
22060 {% if !server_mode %}
22061 <div class="watched-bar-right">
22062 <button type="button" class="btn" id="add-watched-btn">
22063 <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>
22064 Choose
22065 </button>
22066 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
22067 <input type="hidden" name="redirect_to" value="/compare-scans">
22068 <button type="submit" class="btn">↻ Refresh</button>
22069 </form>
22070 </div>
22071 {% endif %}
22072 </div>
22073 {% if total_scans > 0 %}
22074 <div class="summary-strip">
22075 <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>
22076 <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>
22077 <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>
22078 <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>
22079 </div>
22080 {% endif %}
22081 <section class="panel">
22082 <div class="panel-header">
22083 <div>
22084 <h1>Compare Scans</h1>
22085 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
22086 </div>
22087 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
22088 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
22089 <button class="btn primary" id="compare-btn" disabled>
22090 <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>
22091 Compare <span class="sel-count" id="sel-count">0/2</span>
22092 </button>
22093 </div>
22094 </div>
22095 </div>
22096
22097 {% if entries.is_empty() %}
22098 <div class="empty-state">
22099 <strong>No scans yet</strong>
22100 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.
22101 </div>
22102 {% else %}
22103 <div class="filter-row">
22104 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
22105 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
22106 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
22107 </div>
22108 <div class="scope-panel hidden" id="scope-panel">
22109 <div class="scope-panel-label">
22110 <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>
22111 Compare scope — choose what to include
22112 </div>
22113 <div class="scope-options" id="scope-options"></div>
22114 </div>
22115 {% if total_scans > 0 %}
22116 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
22117 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
22118 <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>
22119 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
22120 </div>
22121 </div>
22122 {% endif %}
22123 <div class="table-wrap">
22124 <table id="compare-table">
22125 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
22126 <thead>
22127 <tr id="compare-thead">
22128 <th><div class="col-resize-handle"></div></th>
22129 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22130 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22131 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
22132 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22133 <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>
22134 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22135 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22136 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22137 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22138 <th>Submodules<div class="col-resize-handle"></div></th>
22139 </tr>
22140 </thead>
22141 <tbody id="compare-tbody">
22142 {% for entry in entries %}
22143 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
22144 data-timestamp="{{ entry.timestamp }}"
22145 data-project="{{ entry.project_label }}"
22146 data-files="{{ entry.files_analyzed }}"
22147 data-code="{{ entry.code_lines }}"
22148 data-comments="{{ entry.comment_lines }}"
22149 data-blank="{{ entry.blank_lines }}"
22150 data-branch="{{ entry.git_branch }}"
22151 data-commit="{{ entry.git_commit }}"
22152 data-submodules="{{ entry.submodule_names_csv }}">
22153 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
22154 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
22155 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
22156 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
22157 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
22158 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
22159 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
22160 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
22161 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
22162 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
22163 <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>
22164 </tr>
22165 {% endfor %}
22166 </tbody>
22167 </table>
22168 </div>
22169 <div class="pagination">
22170 <span class="pagination-info" id="pagination-info"></span>
22171 <div class="pagination-btns" id="pagination-btns"></div>
22172 <div class="flex-row">
22173 <span class="per-page-label">Show</span>
22174 <select class="per-page" id="per-page-sel">
22175 <option value="10">10 per page</option>
22176 <option value="25" selected>25 per page</option>
22177 <option value="50">50 per page</option>
22178 <option value="100">100 per page</option>
22179 </select>
22180 <span class="per-page-label" id="page-range-label"></span>
22181 </div>
22182 </div>
22183 {% endif %}
22184 </section>
22185 </div>
22186
22187 <footer class="site-footer">
22188 local code analysis - metrics, history and reports
22189 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22190 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22191 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22192 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22193 · <a href="/api-docs" rel="noopener">REST API</a>
22194 </footer>
22195
22196 <script nonce="{{ csp_nonce }}">
22197 (function () {
22198 // ── Theme ──────────────────────────────────────────────────────────────
22199 var storageKey = 'oxide-sloc-theme';
22200 var body = document.body;
22201 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22202 var toggle = document.getElementById('theme-toggle');
22203 if (toggle) toggle.addEventListener('click', function () {
22204 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22205 body.classList.toggle('dark-theme', next === 'dark');
22206 try { localStorage.setItem(storageKey, next); } catch(e) {}
22207 });
22208
22209 // ── State ─────────────────────────────────────────────────────────────
22210 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
22211 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
22212 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
22213
22214 // ── Stat chips ────────────────────────────────────────────────────────
22215 (function() {
22216 var projects = {}, latestTs = '', latestRow = null;
22217 allRows.forEach(function(r) {
22218 var p = r.dataset.project || ''; if (p) projects[p] = true;
22219 var ts = r.dataset.timestamp || '';
22220 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
22221 });
22222 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();}
22223 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>':'');}
22224 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
22225 if (latestRow) {
22226 setChipVal('agg-code', latestRow.dataset.code);
22227 setChipVal('agg-files', latestRow.dataset.files);
22228 }
22229 })();
22230
22231 // ── Branch filter population ──────────────────────────────────────────
22232 (function() {
22233 var branches = {};
22234 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
22235 var sel = document.getElementById('branch-filter');
22236 if (sel) Object.keys(branches).sort().forEach(function(b) {
22237 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
22238 });
22239 })();
22240
22241 // ── Filter ────────────────────────────────────────────────────────────
22242 function getFilteredRows() {
22243 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
22244 var branch = ((document.getElementById('branch-filter') || {}).value || '');
22245 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
22246 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
22247 if (branch && (r.dataset.branch || '') !== branch) return false;
22248 return true;
22249 });
22250 }
22251
22252 // ── Pagination ────────────────────────────────────────────────────────
22253 function renderPage() {
22254 var filtered = getFilteredRows();
22255 var total = filtered.length;
22256 var totalPages = Math.max(1, Math.ceil(total / perPage));
22257 currentPage = Math.min(currentPage, totalPages);
22258 var start = (currentPage - 1) * perPage;
22259 var end = Math.min(start + perPage, total);
22260 var shown = {};
22261 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
22262 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
22263 r.style.display = shown[r.dataset.run] ? '' : 'none';
22264 });
22265 var rl = document.getElementById('page-range-label');
22266 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
22267 var info = document.getElementById('pagination-info');
22268 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
22269 var btns = document.getElementById('pagination-btns');
22270 if (!btns) return;
22271 btns.innerHTML = '';
22272 function makeBtn(lbl, pg, active, disabled) {
22273 var b = document.createElement('button');
22274 b.className = 'pg-btn' + (active ? ' active' : '');
22275 b.textContent = lbl; b.disabled = disabled;
22276 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
22277 return b;
22278 }
22279 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
22280 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
22281 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
22282 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
22283 }
22284
22285 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
22286 window.applyFilters = function() { currentPage = 1; renderPage(); };
22287
22288 // ── Sorting ───────────────────────────────────────────────────────────
22289 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
22290 function doSort(col, type, order) {
22291 var tbody = document.getElementById('compare-tbody');
22292 if (!tbody) return;
22293 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22294 rows.sort(function(a, b) {
22295 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
22296 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
22297 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
22298 return va < vb ? 1 : va > vb ? -1 : 0;
22299 });
22300 rows.forEach(function(r) { tbody.appendChild(r); });
22301 currentPage = 1; renderPage();
22302 }
22303 sortHeaders.forEach(function(th) {
22304 th.addEventListener('click', function(e) {
22305 if (e.target.classList.contains('col-resize-handle')) return;
22306 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
22307 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
22308 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22309 th.classList.add('sort-' + sortOrder);
22310 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
22311 doSort(col, type, sortOrder);
22312 });
22313 });
22314
22315 // Apply default sort (timestamp desc) on initial load
22316 (function() {
22317 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
22318 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
22319 })();
22320
22321 // ── Column resize ─────────────────────────────────────────────────────
22322 (function() {
22323 var table = document.getElementById('compare-table');
22324 if (!table) return;
22325 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
22326 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
22327 ths.forEach(function(th, i) {
22328 var handle = th.querySelector('.col-resize-handle');
22329 if (!handle || !cols[i]) return;
22330 var startX, startW;
22331 handle.addEventListener('mousedown', function(e) {
22332 e.stopPropagation(); e.preventDefault();
22333 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
22334 handle.classList.add('dragging');
22335 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
22336 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
22337 document.addEventListener('mousemove', onMove);
22338 document.addEventListener('mouseup', onUp);
22339 });
22340 });
22341 })();
22342
22343 // ── Reset view ────────────────────────────────────────────────────────
22344 window.resetView = function() {
22345 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
22346 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
22347 sortCol = null; sortOrder = 'asc';
22348 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22349 var tbody = document.getElementById('compare-tbody');
22350 if (tbody) {
22351 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22352 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
22353 rows.forEach(function(r) { tbody.appendChild(r); });
22354 }
22355 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
22356 var table = document.getElementById('compare-table');
22357 currentPage = 1; renderPage();
22358 currentPage = 1; renderPage();
22359 };
22360
22361 renderPage();
22362
22363 // ── Row selection state ───────────────────────────────────────────────
22364 var selected = [];
22365 function updateCompareBtn() {
22366 var btn = document.getElementById('compare-btn');
22367 var cnt = document.getElementById('sel-count');
22368 if (!btn) return;
22369 btn.disabled = selected.length !== 2;
22370 if (cnt) cnt.textContent = selected.length + '/2';
22371 }
22372
22373 function toggleRow(row) {
22374 var vid = row.dataset.vid || row.dataset.run;
22375 var idx = selected.indexOf(vid);
22376 if (idx >= 0) {
22377 selected.splice(idx, 1);
22378 row.classList.remove('selected');
22379 var b = document.getElementById('badge-' + vid);
22380 if (b) b.textContent = '';
22381 } else {
22382 if (selected.length >= 2) return;
22383 selected.push(vid);
22384 row.classList.add('selected');
22385 }
22386 selected.forEach(function(v, i) {
22387 var b = document.getElementById('badge-' + v);
22388 if (b) b.textContent = i + 1;
22389 });
22390 updateCompareBtn();
22391 buildScopePanel();
22392 }
22393
22394 // ── Scope panel ───────────────────────────────────────────────────────
22395 var selectedScope = 'all';
22396
22397 function buildScopePanel() {
22398 var panel = document.getElementById('scope-panel');
22399 var opts = document.getElementById('scope-options');
22400 if (!panel || !opts) return;
22401 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22402
22403 // Collect union of submodules from both selected rows.
22404 var allSubs = {};
22405 selected.forEach(function(vid) {
22406 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
22407 if (!row) return;
22408 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
22409 });
22410 var subList = Object.keys(allSubs).sort();
22411 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22412
22413 panel.classList.remove('hidden');
22414 opts.innerHTML = '';
22415
22416 function makeOption(value, label, title) {
22417 var div = document.createElement('div');
22418 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
22419 div.dataset.scopeValue = value;
22420 if (title) div.title = title;
22421 var radio = document.createElement('span');
22422 radio.className = 'scope-option-radio';
22423 var lbl = document.createElement('span');
22424 lbl.textContent = label;
22425 div.appendChild(radio);
22426 div.appendChild(lbl);
22427 div.addEventListener('click', function() {
22428 selectedScope = value;
22429 opts.querySelectorAll('.scope-option').forEach(function(o) {
22430 o.classList.toggle('selected', o.dataset.scopeValue === value);
22431 });
22432 });
22433 return div;
22434 }
22435
22436 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
22437 var sep = document.createElement('span');
22438 sep.className = 'scope-option-sep';
22439 opts.appendChild(sep);
22440 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
22441 subList.forEach(function(s) {
22442 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
22443 });
22444 }
22445
22446 function doCompare() {
22447 if (selected.length !== 2) return;
22448 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
22449 if (selectedScope === 'super') url += '&scope=super';
22450 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
22451 window.location.href = url;
22452 }
22453
22454 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
22455 var cbtn = document.getElementById('compare-btn');
22456 if (cbtn) cbtn.addEventListener('click', doCompare);
22457 var pfEl = document.getElementById('project-filter');
22458 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
22459 var bfEl = document.getElementById('branch-filter');
22460 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
22461 var rvBtn = document.getElementById('reset-view-btn');
22462 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
22463 var ppSel = document.getElementById('per-page-sel');
22464 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
22465
22466 var cmpTbody = document.getElementById('compare-tbody');
22467 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
22468 var row = e.target.closest('.compare-row');
22469 if (row) toggleRow(row);
22470 });
22471
22472 (function randomizeWatermarks() {
22473 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22474 if (!wms.length) return;
22475 var placed = [];
22476 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;}
22477 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];}
22478 var half=Math.floor(wms.length/2);
22479 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;});
22480 })();
22481
22482 (function spawnCodeParticles() {
22483 var container = document.getElementById('code-particles');
22484 if (!container) return;
22485 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'];
22486 for (var i = 0; i < 38; i++) {
22487 (function(idx) {
22488 var el = document.createElement('span');
22489 el.className = 'code-particle';
22490 el.textContent = snippets[idx % snippets.length];
22491 var left = Math.random() * 94 + 2;
22492 var top = Math.random() * 88 + 6;
22493 var dur = (Math.random() * 10 + 9).toFixed(1);
22494 var delay = (Math.random() * 18).toFixed(1);
22495 var rot = (Math.random() * 26 - 13).toFixed(1);
22496 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22497 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';
22498 container.appendChild(el);
22499 })(i);
22500 }
22501 })();
22502
22503 // ── Watched folder picker ─────────────────────────────────────────────
22504 (function() {
22505 var btn = document.getElementById('add-watched-btn');
22506 if (!btn) return;
22507 btn.addEventListener('click', function() {
22508 fetch('/pick-directory?kind=reports')
22509 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
22510 .then(function(data) {
22511 if (!data.cancelled && data.selected_path) {
22512 var form = document.createElement('form');
22513 form.method = 'POST';
22514 form.action = '/watched-dirs/add';
22515 var ri = document.createElement('input');
22516 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
22517 var fi = document.createElement('input');
22518 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
22519 form.appendChild(ri); form.appendChild(fi);
22520 document.body.appendChild(form);
22521 form.submit();
22522 }
22523 })
22524 .catch(function(e) { alert('Could not open folder picker: ' + e); });
22525 });
22526 })();
22527
22528 // ── Submodule chip truncation ─────────────────────────────────────────
22529 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
22530 var chips = cell.querySelectorAll('.submod-chip');
22531 var MAX = 4;
22532 if (chips.length <= MAX) return;
22533 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
22534 var badge = document.createElement('span');
22535 badge.className = 'submod-overflow-badge';
22536 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
22537 badge.textContent = '+' + (chips.length - MAX) + ' more';
22538 cell.appendChild(badge);
22539 cell.style.maxHeight = 'none';
22540 });
22541 })();
22542 </script>
22543 <script nonce="{{ csp_nonce }}">
22544 (function(){
22545 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'}];
22546 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);});}
22547 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22548 function init(){
22549 var btn=document.getElementById('settings-btn');if(!btn)return;
22550 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22551 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>';
22552 document.body.appendChild(m);
22553 var g=document.getElementById('scheme-grid');
22554 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);});
22555 var cl=document.getElementById('settings-close');
22556 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);
22557 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');});
22558 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22559 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22560 }
22561 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22562 }());
22563 </script>
22564 <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=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>
22565</body>
22566</html>
22567"##,
22568 ext = "html"
22569)]
22570struct CompareSelectTemplate {
22571 version: &'static str,
22572 entries: Vec<HistoryEntryRow>,
22573 total_scans: usize,
22574 watched_dirs: Vec<String>,
22575 csp_nonce: String,
22576 server_mode: bool,
22577}
22578
22579#[derive(Template)]
22582#[template(
22583 source = r##"
22584<!doctype html>
22585<html lang="en">
22586<head>
22587 <meta charset="utf-8">
22588 <meta name="viewport" content="width=device-width, initial-scale=1">
22589 <title>OxideSLOC | Scan Delta</title>
22590 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22591 <style nonce="{{ csp_nonce }}">
22592 :root {
22593 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
22594 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
22595 --nav:#283790; --nav-2:#013e6b;
22596 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
22597 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
22598 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
22599 }
22600 body.dark-theme {
22601 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
22602 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
22603 }
22604 *{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;}
22605 .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);}
22606 .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;}
22607 .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));}
22608 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22609 .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;}
22610 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
22611 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22612 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
22613 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;}
22614 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22615 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22616 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22617 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22618 .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;}
22619 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22620 .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);}
22621 .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;}
22622 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22623 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22624 .settings-modal-body{padding:14px 16px 16px;}
22625 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22626 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22627 .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;}
22628 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22629 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22630 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22631 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22632 .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;}
22633 .tz-select:focus{border-color:var(--oxide);}
22634 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
22635 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22636 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
22637 .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;}
22638 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
22639 .hero-body{display:block;}
22640 .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;}
22641 .btn-back:hover{background:var(--line);}
22642 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
22643 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
22644 .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;}
22645 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
22646 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;}
22647 .muted{color:var(--muted);font-size:14px;}
22648 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
22649 .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;}
22650 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
22651 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
22652 .vpill-arrow{font-size:20px;color:var(--muted);}
22653 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
22654 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
22655 .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;}
22656 .delta-card.delta-card-wide{padding:22px 24px;}
22657 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
22658 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
22659 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
22660 .delta-card-from{font-size:15px;color:var(--muted);}
22661 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
22662 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
22663 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
22664 .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%;}
22665 .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;}
22666 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
22667 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
22668 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
22669 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
22670 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
22671 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
22672 .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;}
22673 .meta-card-commit:hover{color:var(--oxide);}
22674 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
22675 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
22676 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
22677 .meta-value{color:var(--text);font-size:13px;}
22678 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
22679 .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.55;width:230px;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;}
22680 .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);}
22681 .delta-card:hover .dc-tip{display:block;}
22682 .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;}
22683 .export-btn:hover{background:var(--line);}
22684 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
22685 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
22686 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
22687 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
22688 .delta-card-change.zero{color:var(--muted);background:transparent;}
22689 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
22690 .delta-card-pct.pos{color:var(--pos);}
22691 .delta-card-pct.neg{color:var(--neg);}
22692 .delta-card-pct.zero{color:var(--muted);}
22693 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
22694 .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;}
22695 .insight-card.insight-flag{border-color:var(--oxide);}
22696 .insight-card:hover .dc-tip{display:block;}
22697 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
22698 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
22699 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
22700 .insight-label.flag{color:var(--oxide);}
22701 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
22702 .insight-val.pos{color:var(--pos);}
22703 .insight-val.neg{color:var(--neg);}
22704 .insight-val.high{color:#c0392a;}
22705 .insight-val.med{color:#926000;}
22706 .insight-val.low{color:var(--pos);}
22707 body.dark-theme .insight-val.high{color:#ff6b6b;}
22708 body.dark-theme .insight-val.med{color:#f0c060;}
22709 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
22710 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
22711 .fc-row{display:flex;align-items:center;gap:8px;}
22712 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
22713 .fc-label{color:var(--muted);}
22714 .fc-modified .fc-count{color:#926000;}
22715 .fc-added .fc-count{color:var(--pos);}
22716 .fc-removed .fc-count{color:var(--neg);}
22717 .fc-unchanged .fc-count{color:var(--muted);}
22718 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
22719 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
22720 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
22721 .chip.modified{background:#fff2d8;color:#926000;}
22722 .chip.added{background:#e8f5ed;color:#1a8f47;}
22723 .chip.removed{background:#fdeaea;color:#b33b3b;}
22724 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
22725 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
22726 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
22727 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
22728 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
22729 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
22730 .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;}
22731 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
22732 .tab-btn:hover:not(.active){background:var(--line);}
22733 .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;}
22734 .btn-reset:hover{background:var(--line);}
22735 .table-wrap{width:100%;overflow-x:auto;}
22736 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
22737 th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
22738 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
22739 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
22740 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
22741 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
22742 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
22743 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
22744 tr:last-child td{border-bottom:none;}
22745 tr.row-added td{background:rgba(26,143,71,0.06);}
22746 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
22747 tr.row-modified td{background:rgba(146,96,0,0.05);}
22748 tr.row-unchanged td{opacity:.6;}
22749 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
22750 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
22751 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
22752 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
22753 .status-badge.modified{background:#fff2d8;color:#926000;}
22754 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
22755 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
22756 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
22757 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
22758 .delta-val{font-weight:700;}
22759 .delta-val.pos{color:var(--pos);}
22760 .delta-val.neg{color:var(--neg);}
22761 .delta-val.zero{color:var(--muted);}
22762 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
22763 .from-to strong{color:var(--text);}
22764 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22765 .site-footer a{color:var(--muted);}
22766 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
22767 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
22768 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22769 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22770 .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;}
22771 .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;}
22772 .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;}
22773 @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));}}
22774 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
22775 .path-link:hover{color:var(--oxide-2);}
22776 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
22777 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
22778 a.vpill-id:hover{color:var(--oxide);}
22779 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
22780 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
22781 .pagination-info{font-size:13px;color:var(--muted);}
22782 .pagination-btns{display:flex;gap:6px;}
22783 .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;}
22784 .pg-btn:hover:not(:disabled){background:var(--line);}
22785 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22786 .pg-btn:disabled{opacity:.35;cursor:default;}
22787 .per-page-label{font-size:13px;color:var(--muted);}
22788 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;}
22789 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22790 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
22791 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
22792 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
22793 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
22794 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
22795 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
22796 .tab-btn.tab-unchanged{color:var(--muted);}
22797 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
22798 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
22799 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
22800 .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;}
22801 .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;}
22802 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
22803 .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;}
22804 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
22805 .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;}
22806 .submod-scope-btn:hover{background:var(--line);}
22807 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22808 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
22809 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
22810 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
22811 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
22812 body.dark-theme .ic-card{background:var(--surface-2);}
22813 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
22814 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
22815 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
22816 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
22817 #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;}
22818 </style>
22819</head>
22820<body>
22821 <div class="background-watermarks" aria-hidden="true">
22822 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22823 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22824 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22825 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22826 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22827 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22828 </div>
22829 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22830 <div class="top-nav">
22831 <div class="top-nav-inner">
22832 <a class="brand" href="/">
22833 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22834 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
22835 </a>
22836 <div class="nav-right">
22837 <a class="nav-pill" href="/">Home</a>
22838 <div class="nav-dropdown">
22839 <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>
22840 <div class="nav-dropdown-menu">
22841 <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>
22842 </div>
22843 </div>
22844 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22845 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22846 <div class="nav-dropdown">
22847 <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>
22848 <div class="nav-dropdown-menu">
22849 <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>
22850 </div>
22851 </div>
22852 <div class="server-status-wrap" id="server-status-wrap">
22853 <div class="nav-pill server-online-pill" id="server-status-pill">
22854 <span class="status-dot" id="status-dot"></span>
22855 <span id="server-status-label">Server</span>
22856 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22857 </div>
22858 <div class="server-status-tip">
22859 OxideSLOC is running — accessible on your network.
22860 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22861 </div>
22862 </div>
22863 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22864 <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>
22865 </button>
22866 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22867 <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>
22868 <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>
22869 </button>
22870 </div>
22871 </div>
22872 </div>
22873
22874 <div class="page">
22875 <section class="hero">
22876 <div class="hero-header">
22877 <div>
22878 <h1 class="delta-title">Scan Delta</h1>
22879 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
22880 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
22881 {% if let Some(sub) = active_submodule %}
22882 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
22883 {% else if super_scope_active %}
22884 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
22885 {% else %}
22886 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
22887 {% endif %}
22888 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
22889 </div>
22890 </div>
22891 <a class="btn-back" href="/compare-scans">
22892 <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>
22893 Compare Scans
22894 </a>
22895 </div>
22896 {% if has_any_submodule_data %}
22897 <div class="submod-scope-bar">
22898 <span class="submod-scope-label">
22899 <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>
22900 Scope:
22901 </span>
22902 <div class="submod-scope-divider"></div>
22903 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
22904 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
22905 title="All files — super-repo and all submodules combined">Full scan</a>
22906 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
22907 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
22908 title="Only files that are not part of any submodule">Super-repo only</a>
22909 {% for sub in submodule_options %}
22910 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
22911 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
22912 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
22913 {% endfor %}
22914 </div>
22915 {% endif %}
22916 <div class="hero-body">
22917 <div class="meta-strip">
22918 <div class="delta-card delta-card-meta">
22919 <div class="meta-card-header">
22920 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
22921 <div class="meta-card-project-col">
22922 <div class="meta-card-project">{{ project_name }}</div>
22923 {% if has_any_submodule_data %}
22924 {% if let Some(sub) = active_submodule %}
22925 <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>
22926 {% else if super_scope_active %}
22927 <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>
22928 {% else %}
22929 <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>
22930 {% endif %}
22931 {% endif %}
22932 </div>
22933 </div>
22934 {% if !baseline_git_commit.is_empty() %}
22935 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
22936 {% else %}
22937 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
22938 {% endif %}
22939 <div class="meta-card-rows">
22940 <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>
22941 <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>
22942 <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>
22943 <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>
22944 {% if let Some(tags) = baseline_git_tags %}
22945 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22946 {% endif %}
22947 </div>
22948 </div>
22949 <div class="delta-card delta-card-meta">
22950 <div class="meta-card-header">
22951 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
22952 <div class="meta-card-project-col">
22953 <div class="meta-card-project">{{ project_name }}</div>
22954 {% if has_any_submodule_data %}
22955 {% if let Some(sub) = active_submodule %}
22956 <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>
22957 {% else if super_scope_active %}
22958 <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>
22959 {% else %}
22960 <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>
22961 {% endif %}
22962 {% endif %}
22963 </div>
22964 </div>
22965 {% if !current_git_commit.is_empty() %}
22966 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
22967 {% else %}
22968 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
22969 {% endif %}
22970 <div class="meta-card-rows">
22971 <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>
22972 <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>
22973 <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>
22974 <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>
22975 {% if let Some(tags) = current_git_tags %}
22976 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22977 {% endif %}
22978 </div>
22979 </div>
22980 </div>
22981 <div class="delta-strip">
22982 <div class="delta-card">
22983 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
22984 <div class="delta-card-label">Code lines</div>
22985 <div class="delta-card-from">Before: {{ baseline_code }}</div>
22986 <div class="delta-card-to">{{ current_code }}</div>
22987 {% 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>
22988 {% 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>
22989 {% else %}<div class="delta-card-pct zero">±0%</div>
22990 {% endif %}
22991 </div>
22992 <div class="delta-card">
22993 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
22994 <div class="delta-card-label">Files analyzed</div>
22995 <div class="delta-card-from">Before: {{ baseline_files }}</div>
22996 <div class="delta-card-to">{{ current_files }}</div>
22997 {% 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>
22998 {% 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>
22999 {% else %}<div class="delta-card-pct zero">±0%</div>
23000 {% endif %}
23001 </div>
23002 <div class="delta-card">
23003 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
23004 <div class="delta-card-label">Comment lines</div>
23005 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
23006 <div class="delta-card-to">{{ current_comments }}</div>
23007 {% 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>
23008 {% 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>
23009 {% else %}<div class="delta-card-pct zero">±0%</div>
23010 {% endif %}
23011 </div>
23012 {{ coverage_delta_card|safe }}
23013 <div class="delta-card delta-card-wide">
23014 <div class="dc-tip">Per-file breakdown. Modified = at least one count changed. Unchanged = identical counts in both scans. Added/Removed = only in one scan.</div>
23015 <div class="delta-card-label">File changes</div>
23016 <div class="file-changes-grid">
23017 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
23018 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
23019 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
23020 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
23021 </div>
23022 </div>
23023 </div>
23024 <div class="insights-panel">
23025 <div class="insight-card">
23026 <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans. Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
23027 <div class="insight-label">Lines Added</div>
23028 <div class="insight-val pos">+{{ code_lines_added }}</div>
23029 <div class="insight-sub">New or grown source lines</div>
23030 </div>
23031 <div class="insight-card">
23032 <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans. Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
23033 <div class="insight-label">Lines Removed</div>
23034 <div class="insight-val neg">−{{ code_lines_removed }}</div>
23035 <div class="insight-sub">Deleted or shrunk source lines</div>
23036 </div>
23037 <div class="insight-card">
23038 <div class="dc-tip up">Measures total editing activity relative to codebase size. Formula: (lines added + lines removed) ÷ baseline code lines × 100%. Above 20% = high activity, 5–20% = normal velocity, below 5% = stable.</div>
23039 <div class="insight-label">Churn Rate</div>
23040 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
23041 <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>
23042 </div>
23043 {% if scope_flag %}
23044 <div class="insight-card insight-flag">
23045 <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new. Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline. This often signals a large feature branch, a bulk import, or a generated-file inclusion. Review the file-level delta below to confirm scope.{% endif %}</div>
23046 <div class="insight-label flag">Scope Signal</div>
23047 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
23048 <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>
23049 </div>
23050 {% endif %}
23051 </div>
23052 </div>
23053 </section>
23054
23055 <section class="panel" id="inline-charts-section">
23056 <h2>Scan Delta Charts</h2>
23057 <div class="ic-grid">
23058 <div class="ic-card">
23059 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
23060 <div class="ic-leg"><span><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = before)</span></div>
23061 <div id="ic-c1"></div>
23062 </div>
23063 <div class="ic-card" id="ic-lang-card">
23064 <div class="ic-card-h2">Language Code Delta</div>
23065 <div id="ic-c3"></div>
23066 </div>
23067 <div class="ic-card">
23068 <div class="ic-card-h2">Delta by Metric</div>
23069 <div id="ic-c2"></div>
23070 </div>
23071 <div class="ic-card">
23072 <div class="ic-card-h2">File Change Distribution</div>
23073 <div id="ic-c4"></div>
23074 </div>
23075 </div>
23076 </section>
23077
23078 <section class="panel">
23079 <h2>File-level delta</h2>
23080 <div class="filter-tabs-row">
23081 <div class="filter-tabs">
23082 <button class="tab-btn tab-all active" data-filter="all">All</button>
23083 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
23084 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
23085 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
23086 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
23087 </div>
23088 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
23089 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
23090 <div class="export-group">
23091 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
23092 <button type="button" class="export-btn" id="delta-csv-btn">
23093 <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>
23094 CSV
23095 </button>
23096 <button type="button" class="export-btn" id="delta-xls-btn">
23097 <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>
23098 Excel
23099 </button>
23100 <button type="button" class="export-btn" id="delta-charts-btn">
23101 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="2" y1="20" x2="22" y2="20"/><rect x="3" y="13" width="4" height="7" rx="1"/><rect x="10" y="7" width="4" height="13" rx="1"/><rect x="17" y="2" width="4" height="18" rx="1"/></svg>
23102 Charts
23103 </button>
23104 </div>
23105 </div>
23106 </div>
23107
23108 <div class="table-wrap">
23109 <table id="delta-table">
23110 <colgroup>
23111 <col>
23112 <col>
23113 <col>
23114 <col>
23115 <col>
23116 <col>
23117 <col>
23118 </colgroup>
23119 <thead>
23120 <tr id="delta-thead">
23121 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
23122 <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>
23123 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
23124 <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>
23125 <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>
23126 <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>
23127 <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>
23128 </tr>
23129 </thead>
23130 <tbody id="delta-tbody">
23131 {% for row in file_rows %}
23132 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
23133 data-path="{{ row.relative_path }}"
23134 data-language="{{ row.language }}"
23135 data-baseline-code="{{ row.baseline_code }}"
23136 data-current-code="{{ row.current_code }}"
23137 data-code-delta="{{ row.code_delta_str }}"
23138 data-comment-delta="{{ row.comment_delta_str }}"
23139 data-total-delta="{{ row.total_delta_str }}"
23140 data-orig-idx="">
23141 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
23142 <td class="hide-sm">{{ row.language }}</td>
23143 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
23144 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
23145 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
23146 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
23147 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
23148 </tr>
23149 {% endfor %}
23150 </tbody>
23151 </table>
23152 </div>
23153 <div class="pagination">
23154 <span class="pagination-info" id="pg-info"></span>
23155 <div class="pagination-btns" id="pg-btns"></div>
23156 <div class="flex-row">
23157 <span class="per-page-label">Show</span>
23158 <select class="per-page" id="per-page-sel">
23159 <option value="10">10 per page</option>
23160 <option value="25" selected>25 per page</option>
23161 <option value="50">50 per page</option>
23162 <option value="100">100 per page</option>
23163 </select>
23164 <span class="per-page-label" id="pg-range-label"></span>
23165 </div>
23166 </div>
23167 </section>
23168 </div>
23169
23170 <div id="ic-tt"></div>
23171
23172 <footer class="site-footer">
23173 local code analysis - metrics, history and reports
23174 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
23175 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23176 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23177 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23178 · <a href="/api-docs" rel="noopener">REST API</a>
23179 </footer>
23180
23181 <script nonce="{{ csp_nonce }}">
23182 (function () {
23183 var storageKey = 'oxide-sloc-theme';
23184 var body = document.body;
23185 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
23186 var toggle = document.getElementById('theme-toggle');
23187 if (toggle) toggle.addEventListener('click', function () {
23188 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
23189 body.classList.toggle('dark-theme', next === 'dark');
23190 try { localStorage.setItem(storageKey, next); } catch(e) {}
23191 });
23192
23193 (function randomizeWatermarks() {
23194 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23195 if (!wms.length) return;
23196 var placed = [];
23197 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;}
23198 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];}
23199 var half=Math.floor(wms.length/2);
23200 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;});
23201 })();
23202
23203 (function spawnCodeParticles() {
23204 var container = document.getElementById('code-particles');
23205 if (!container) return;
23206 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'];
23207 for (var i = 0; i < 38; i++) {
23208 (function(idx) {
23209 var el = document.createElement('span');
23210 el.className = 'code-particle';
23211 el.textContent = snippets[idx % snippets.length];
23212 var left = Math.random() * 94 + 2;
23213 var top = Math.random() * 88 + 6;
23214 var dur = (Math.random() * 10 + 9).toFixed(1);
23215 var delay = (Math.random() * 18).toFixed(1);
23216 var rot = (Math.random() * 26 - 13).toFixed(1);
23217 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23218 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';
23219 container.appendChild(el);
23220 })(i);
23221 }
23222 })();
23223 })();
23224
23225 var activeStatusFilter = 'all';
23226 var deltaPerPage = 25, deltaCurrPage = 1;
23227
23228 function openFolder(path) {
23229 fetch('/open-path?path=' + encodeURIComponent(path))
23230 .then(function (r) { return r.json(); })
23231 .then(function (d) {
23232 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23233 })
23234 .catch(function () {});
23235 }
23236
23237 function getDeltaFilteredRows() {
23238 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
23239 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
23240 });
23241 }
23242
23243 function renderDeltaPage() {
23244 var filtered = getDeltaFilteredRows();
23245 var total = filtered.length;
23246 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
23247 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
23248 var start = (deltaCurrPage - 1) * deltaPerPage;
23249 var end = Math.min(start + deltaPerPage, total);
23250 var shownSet = {};
23251 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
23252 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
23253 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
23254 });
23255 var rl = document.getElementById('pg-range-label');
23256 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
23257 var info = document.getElementById('pg-info');
23258 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
23259 var btns = document.getElementById('pg-btns');
23260 if (!btns) return;
23261 btns.innerHTML = '';
23262 if (totalPages <= 1) return;
23263 function makeBtn(lbl, pg, active, disabled) {
23264 var b = document.createElement('button');
23265 b.className = 'pg-btn' + (active ? ' active' : '');
23266 b.textContent = lbl; b.disabled = disabled;
23267 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
23268 return b;
23269 }
23270 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
23271 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
23272 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
23273 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
23274 }
23275
23276 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
23277
23278 function filterRows(status, btn) {
23279 activeStatusFilter = status;
23280 deltaCurrPage = 1;
23281 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
23282 b.classList.remove('active');
23283 });
23284 if (btn) btn.classList.add('active');
23285 renderDeltaPage();
23286 }
23287
23288 // ── Sorting ──────────────────────────────────────────────────────────────
23289 var sortCol = null, sortOrder = 'asc';
23290 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
23291 (function() {
23292 var tbody = document.getElementById('delta-tbody');
23293 if (!tbody) return;
23294 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23295 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
23296 })();
23297
23298 function parseDeltaNum(str) {
23299 if (!str || str === '—') return 0;
23300 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
23301 }
23302
23303 sortHeaders.forEach(function(th) {
23304 th.addEventListener('click', function(e) {
23305 if (e.target.classList.contains('col-resize-handle')) return;
23306 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
23307 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
23308 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23309 th.classList.add('sort-' + sortOrder);
23310 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
23311 var tbody = document.getElementById('delta-tbody');
23312 if (!tbody) return;
23313 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23314 rows.sort(function(a, b) {
23315 var va, vb;
23316 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
23317 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
23318 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
23319 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
23320 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23321 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23322 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23323 else { va = ''; vb = ''; }
23324 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
23325 return va < vb ? 1 : va > vb ? -1 : 0;
23326 });
23327 rows.forEach(function(r) { tbody.appendChild(r); });
23328 deltaCurrPage = 1;
23329 renderDeltaPage();
23330 var activeBtn = document.querySelector('.tab-btn.active');
23331 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23332 if (activeBtn) activeBtn.classList.add('active');
23333 });
23334 });
23335
23336 // ── Column resize ─────────────────────────────────────────────────────────
23337 (function() {
23338 var table = document.getElementById('delta-table');
23339 if (!table) return;
23340 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
23341 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
23342 ths.forEach(function(th, i) {
23343 var handle = th.querySelector('.col-resize-handle');
23344 if (!handle || !cols[i]) return;
23345 var startX, startW;
23346 handle.addEventListener('mousedown', function(e) {
23347 e.stopPropagation(); e.preventDefault();
23348 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
23349 handle.classList.add('dragging');
23350 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
23351 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
23352 document.addEventListener('mousemove', onMove);
23353 document.addEventListener('mouseup', onUp);
23354 });
23355 });
23356 })();
23357
23358 // ── Reset ─────────────────────────────────────────────────────────────────
23359 window.resetDeltaTable = function() {
23360 sortCol = null; sortOrder = 'asc';
23361 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23362 var tbody = document.getElementById('delta-tbody');
23363 if (tbody) {
23364 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23365 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
23366 rows.forEach(function(r) { tbody.appendChild(r); });
23367 }
23368 var table = document.getElementById('delta-table');
23369 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
23370 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
23371 activeStatusFilter = 'all';
23372 deltaCurrPage = 1;
23373 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23374 var allBtn = document.querySelector('.tab-btn');
23375 if (allBtn) allBtn.classList.add('active');
23376 renderDeltaPage();
23377 };
23378
23379 renderDeltaPage();
23380
23381 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
23382 (function() {
23383 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
23384 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
23385 });
23386 var resetBtn = document.getElementById('delta-reset-btn');
23387 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
23388 var csvBtn = document.getElementById('delta-csv-btn');
23389 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
23390 var xlsBtn = document.getElementById('delta-xls-btn');
23391 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
23392 var chartsBtn = document.getElementById('delta-charts-btn');
23393 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
23394 var ppSel = document.getElementById('per-page-sel');
23395 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
23396 var pathLink = document.getElementById('project-path-link');
23397 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
23398 })();
23399
23400 // ── Export helpers ────────────────────────────────────────────────────────
23401 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
23402 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
23403 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);}
23404 function slocMakeXlsx(fname,sd,dr){
23405 var enc=new TextEncoder();
23406 // CRC-32 table
23407 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;}
23408 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;}
23409 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
23410 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
23411 // Shared string table
23412 var ss=[],si={};
23413 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
23414 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23415 // Worksheet builder — each WS() call gets its own row counter R
23416 function WS(){
23417 var R=0,buf=[];
23418 function cl(c){return String.fromCharCode(65+c);}
23419 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
23420 '<v>'+S(v)+'</v></c>';}
23421 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
23422 (st?' s="'+st+'"':'')+'>'+
23423 '<v>'+(+v)+'</v></c>';}
23424 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
23425 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23426 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
23427 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
23428 '<sheetFormatPr defaultRowHeight="15"/>'+
23429 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
23430 return{sc:sc,nc:nc,row:row,xml:xml};
23431 }
23432 // Language breakdown
23433 var lm={};
23434 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;});
23435 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
23436 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
23437 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
23438 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
23439 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23440 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23441 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):'';}
23442 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
23443 // Summary sheet
23444 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
23445 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
23446 r1(s1(0,proj,2));
23447 r1(s1(0,sd.bts+' → '+sd.cts,2));
23448 r1('');
23449 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
23450 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))));
23451 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))));
23452 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))));
23453 r1('');
23454 r1(s1(0,'FILE CHANGES',8));
23455 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
23456 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
23457 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
23458 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
23459 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
23460 if(langs.length){
23461 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
23462 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
23463 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)));});
23464 }
23465 r1('');r1(s1(0,'SCAN METADATA',8));
23466 r1(s1(1,_blabel)+s1(2,_clabel));
23467 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
23468 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
23469 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"/>');
23470 // File Delta sheet
23471 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
23472 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));
23473 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)));});
23474 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
23475 // Shared strings XML
23476 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23477 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
23478 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
23479 // XLSX file map
23480 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
23481 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>',
23482 '_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>',
23483 '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>',
23484 '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>',
23485 '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>',
23486 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
23487 // ZIP packer — STORED (no compression), compatible with all XLSX readers
23488 var zparts=[],zcds=[],zoff=0,znf=0;
23489 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
23490 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
23491 ].forEach(function(name){
23492 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
23493 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]);
23494 var entry=new Uint8Array(lha.length+nb.length+sz);
23495 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
23496 zparts.push(entry);
23497 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));
23498 var cde=new Uint8Array(cda.length+nb.length);
23499 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
23500 zcds.push(cde);zoff+=entry.length;znf++;
23501 });
23502 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
23503 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]);
23504 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
23505 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
23506 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
23507 zout.set(new Uint8Array(ea),zpos);
23508 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
23509 var xurl=URL.createObjectURL(xblob);
23510 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
23511 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
23512 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
23513 }
23514 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;');}
23515 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
23516 function getExportFilename(ext){return _exportBase+'.'+ext;}
23517
23518 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 }}'};
23519 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;}
23520 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
23521 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
23522 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23523 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23524 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):'';}
23525 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
23526 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)]];}
23527 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
23528 function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){var b=parseInt(tr.getAttribute('data-baseline-code'))||0,c=parseInt(tr.getAttribute('data-current-code'))||0,st=tr.getAttribute('data-status')||'';r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',st,tr.getAttribute('data-baseline-code')||'',tr.getAttribute('data-current-code')||'',tr.getAttribute('data-code-delta')||'',tr.getAttribute('data-comment-delta')||'',tr.getAttribute('data-total-delta')||'',_filePct(b,c,st)]);});return r;}
23529 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
23530 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
23531
23532 // ── Chart HTML report ─────────────────────────────────────────────────────
23533 function slocChartReport(fname, sd, dr) {
23534 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
23535 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23536 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23537 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();}
23538 function px(n){return Math.round(n);}
23539 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
23540 // Language map
23541 var lm={};
23542 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;});
23543 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23544
23545 // Builds onmouse* attrs for interactive tooltip on each SVG element
23546 function barTT(label,val){
23547 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
23548 }
23549
23550 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
23551 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
23552 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23553 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
23554 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
23555 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23556 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"/>';}
23557 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23558 c1mets.forEach(function(m,i){
23559 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23560 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23561 c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
23562 c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
23563 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
23564 c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
23565 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
23566 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
23567 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
23568 });
23569 c1+='</svg>';
23570
23571 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
23572 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
23573 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23574 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
23575 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
23576 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23577 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23578 mets.forEach(function(m,i){
23579 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
23580 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
23581 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
23582 c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
23583 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
23584 if(bw>=52){
23585 c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
23586 }else{
23587 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
23588 c2+='<text x="'+vx2+'" y="'+(y+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
23589 }
23590 });
23591 c2+='</svg>';
23592
23593 // ── Chart 3: Language Code Delta ─────────────────────────────────────
23594 var c3='';
23595 if(langs.length){
23596 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23597 var C3W=550,c3LW=124,c3FW=52;
23598 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
23599 var L3rH=30,C3H=langs.length*L3rH+20;
23600 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23601 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23602 langs.forEach(function(l,i){
23603 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
23604 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
23605 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
23606 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23607 c3+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"'+barTT(l,'Delta: '+vStr+' code lines • '+e.f+' file'+(e.f!==1?'s':''))+'/>';
23608 if(bw>=48){
23609 c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
23610 }else{
23611 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
23612 c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
23613 }
23614 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
23615 });
23616 c3+='</svg>';
23617 }
23618
23619 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
23620 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;});
23621 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23622 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
23623 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23624 var ang=-Math.PI/2;
23625 segs.forEach(function(s){
23626 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23627 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
23628 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
23629 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
23630 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
23631 c4+='<path class="cb" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="white" stroke-width="2.5"'+barTT(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+'/>';
23632 ang+=sw;
23633 });
23634 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
23635 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23636 segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#333">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
23637 c4+='</svg>';
23638
23639 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
23640 var ttJs='var tt=document.getElementById("ox-tt");'+
23641 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
23642 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
23643 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
23644 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
23645 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
23646 'function oxHT(){tt.style.display="none";}';
23647
23648 // body max-width keeps charts from inflating beyond design dimensions on
23649 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
23650 // each chart's height blows up proportionally, breaking the one-page layout.
23651 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;}'+
23652 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
23653 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
23654 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
23655 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
23656 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
23657 'svg{display:block;}'+
23658 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
23659 '#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;}'+
23660 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
23661 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
23662 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
23663 '<div id="ox-tt"><\/div>'+
23664 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
23665 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
23666 '<div class="two-col">'+
23667 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
23668 '<div class="leg">'+
23669 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
23670 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
23671 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
23672 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
23673 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
23674 '<\/div>'+
23675 '<div class="two-col">'+
23676 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
23677 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
23678 '<\/div>'+
23679 '<script>'+ttJs+'<\/script>'+
23680 '<\/body><\/html>';
23681 slocDownload(html, fname, 'text/html;charset=utf-8;');
23682 }
23683 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
23684 // ── Inline delta charts ────────────────────────────────────────────────────
23685 var _icTT=document.getElementById('ic-tt');
23686 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
23687 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';};
23688 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
23689 window.addEventListener('blur',function(){window.icHT();});
23690 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
23691 (function(){
23692 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
23693 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23694 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();}
23695 function px(n){return Math.round(n);}
23696 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23697 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
23698 function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t)icTT(e,t.getAttribute('data-ttl'),t.getAttribute('data-ttv'));});el.addEventListener('mouseout',function(e){if(!e.relatedTarget||!el.contains(e.relatedTarget))icHT();});el.addEventListener('mousemove',function(e){icMT(e);});}
23699 var dr=getDeltaExportRows(),sd=_sd,lm={};
23700 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;});
23701 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23702 // Chart 1: Baseline vs Current grouped bars
23703 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
23704 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23705 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
23706 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23707 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"/>';}
23708 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23709 c1mets.forEach(function(m,i){
23710 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23711 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23712 c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
23713 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"/>';
23714 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
23715 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"/>';
23716 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
23717 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
23718 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
23719 });
23720 c1+='</svg>';
23721 // Chart 2: Delta by Metric
23722 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
23723 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23724 var C2W=530,rH=48,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;
23725 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23726 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23727 mets.forEach(function(m,i){
23728 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2),col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
23729 c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
23730 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
23731 if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
23732 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+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
23733 });
23734 c2+='</svg>';
23735 // Chart 3: Language Code Delta
23736 var c3='';
23737 if(langs.length){
23738 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23739 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;
23740 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23741 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23742 langs.forEach(function(l,i){
23743 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2),col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
23744 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23745 c3+='<rect'+btt(l,'Delta: '+vStr+' code lines • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
23746 if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
23747 else{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
23748 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
23749 });
23750 c3+='</svg>';
23751 }
23752 // Chart 4: File Change Donut
23753 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;});
23754 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23755 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210,c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
23756 if(segs.length===1){
23757 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
23758 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
23759 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
23760 } else {
23761 segs.forEach(function(s){
23762 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23763 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);
23764 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);
23765 c4+='<path'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="white" stroke-width="2.5"/>';
23766 ang+=sw;
23767 });
23768 }
23769 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt(tot)+'</text>';
23770 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23771 segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#444">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
23772 c4+='</svg>';
23773 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
23774 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
23775 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);}
23776 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
23777 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
23778 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent=' /'+el.textContent.replace(/\s+/g,'');});
23779 })();
23780 </script>
23781 <script nonce="{{ csp_nonce }}">
23782 (function(){
23783 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'}];
23784 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);});}
23785 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23786 function init(){
23787 var btn=document.getElementById('settings-btn');if(!btn)return;
23788 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23789 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>';
23790 document.body.appendChild(m);
23791 var g=document.getElementById('scheme-grid');
23792 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);});
23793 var cl=document.getElementById('settings-close');
23794 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);
23795 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');});
23796 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23797 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23798 }
23799 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23800 }());
23801 </script>
23802 <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=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>
23803</body>
23804</html>
23805"##,
23806 ext = "html"
23807)]
23808#[allow(clippy::struct_excessive_bools)]
23810struct CompareTemplate {
23811 version: &'static str,
23812 project_label: String,
23813 baseline_git_commit: String,
23814 current_git_commit: String,
23815 baseline_run_id: String,
23816 current_run_id: String,
23817 baseline_run_id_short: String,
23818 current_run_id_short: String,
23819 baseline_timestamp: String,
23820 baseline_timestamp_utc_ms: i64,
23821 current_timestamp: String,
23822 current_timestamp_utc_ms: i64,
23823 project_path: String,
23824 baseline_code: u64,
23825 current_code: u64,
23826 code_lines_delta_str: String,
23827 code_lines_delta_class: String,
23828 baseline_files: u64,
23829 current_files: u64,
23830 files_analyzed_delta_str: String,
23831 files_analyzed_delta_class: String,
23832 baseline_comments: u64,
23833 current_comments: u64,
23834 comment_lines_delta_str: String,
23835 comment_lines_delta_class: String,
23836 code_lines_pct_str: String,
23837 files_analyzed_pct_str: String,
23838 comment_lines_pct_str: String,
23839 code_lines_added: i64,
23840 code_lines_removed: i64,
23841 new_scope: bool,
23843 churn_rate_str: String,
23844 churn_rate_class: String,
23845 scope_flag: bool,
23846 files_added: usize,
23847 files_removed: usize,
23848 files_modified: usize,
23849 files_unchanged: usize,
23850 file_rows: Vec<CompareFileDeltaRow>,
23851 baseline_git_author: Option<String>,
23852 current_git_author: Option<String>,
23853 baseline_git_branch: String,
23854 current_git_branch: String,
23855 baseline_git_tags: Option<String>,
23856 current_git_tags: Option<String>,
23857 baseline_git_commit_date: Option<String>,
23858 current_git_commit_date: Option<String>,
23859 project_name: String,
23860 submodule_options: Vec<String>,
23862 has_any_submodule_data: bool,
23864 active_submodule: Option<String>,
23866 super_scope_active: bool,
23868 csp_nonce: String,
23869 coverage_delta_card: String,
23871}
23872
23873#[derive(Template)]
23876#[template(
23877 source = r##"
23878<!doctype html>
23879<html lang="en">
23880<head>
23881 <meta charset="utf-8">
23882 <meta name="viewport" content="width=device-width, initial-scale=1">
23883 <title>OxideSLOC | Sign In</title>
23884 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23885 <style nonce="{{ csp_nonce }}">
23886 :root {
23887 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
23888 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
23889 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
23890 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
23891 }
23892 *{box-sizing:border-box;}
23893 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);}
23894 .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);}
23895 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
23896 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
23897 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
23898 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23899 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23900 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23901 .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;}
23902 @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));}}
23903 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
23904 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
23905 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
23906 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
23907 .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;}
23908 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
23909 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;}
23910 input[type=password]:focus{border-color:var(--oxide);}
23911 .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;}
23912 .btn:hover{opacity:.88;}
23913 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
23914 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
23915 </style>
23916</head>
23917<body>
23918 <div class="background-watermarks" aria-hidden="true">
23919 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23920 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23921 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23922 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23923 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23924 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23925 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23926 </div>
23927 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23928<nav class="top-nav">
23929 <a class="brand" href="/">
23930 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
23931 <span class="brand-title">OxideSLOC</span>
23932 </a>
23933</nav>
23934<main class="page">
23935 <div class="card">
23936 <h1>Sign In</h1>
23937 <p class="subtitle">Enter the API key printed when the server started.</p>
23938 {% if has_error %}
23939 <div class="error">Incorrect API key — please try again.</div>
23940 {% endif %}
23941 <form method="POST" action="/auth/login">
23942 <input type="hidden" name="next" value="{{ next_url|e }}">
23943 <label for="key">API Key</label>
23944 <input id="key" type="password" name="key" autocomplete="current-password"
23945 placeholder="Paste your API key here" autofocus>
23946 <button type="submit" class="btn">Sign In</button>
23947 </form>
23948 <p class="hint">
23949 The API key was printed in the terminal when the server started.<br>
23950 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
23951 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
23952 </p>
23953 </div>
23954</main>
23955<script nonce="{{ csp_nonce }}">
23956(function() {
23957 (function randomizeWatermarks() {
23958 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23959 if (!wms.length) return;
23960 var placed = [];
23961 function tooClose(top, left) {
23962 for (var i = 0; i < placed.length; i++) {
23963 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
23964 if (dt < 16 && dl < 12) return true;
23965 }
23966 return false;
23967 }
23968 function pick(leftBand) {
23969 for (var attempt = 0; attempt < 50; attempt++) {
23970 var top = Math.random() * 88 + 2;
23971 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23972 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
23973 }
23974 var top = Math.random() * 88 + 2;
23975 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23976 placed.push([top, left]); return [top, left];
23977 }
23978 var half = Math.floor(wms.length / 2);
23979 wms.forEach(function (img, i) {
23980 var pos = pick(i < half);
23981 var size = Math.floor(Math.random() * 100 + 120);
23982 var rot = (Math.random() * 360).toFixed(1);
23983 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
23984 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;
23985 });
23986 })();
23987 (function spawnCodeParticles() {
23988 var container = document.getElementById('code-particles');
23989 if (!container) return;
23990 var snippets = [
23991 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
23992 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
23993 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
23994 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
23995 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
23996 ];
23997 var count = 38;
23998 for (var i = 0; i < count; i++) {
23999 (function(idx) {
24000 var el = document.createElement('span');
24001 el.className = 'code-particle';
24002 el.textContent = snippets[idx % snippets.length];
24003 var left = Math.random() * 94 + 2;
24004 var top = Math.random() * 88 + 6;
24005 var dur = (Math.random() * 10 + 9).toFixed(1);
24006 var delay = (Math.random() * 18).toFixed(1);
24007 var rot = (Math.random() * 26 - 13).toFixed(1);
24008 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24009 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
24010 container.appendChild(el);
24011 })(i);
24012 }
24013 })();
24014})();
24015</script>
24016</body>
24017</html>
24018"##,
24019 ext = "html"
24020)]
24021pub(crate) struct LoginTemplate {
24022 pub(crate) csp_nonce: String,
24023 pub(crate) has_error: bool,
24024 pub(crate) next_url: String,
24025 pub(crate) lockout_threshold: u32,
24026}
24027
24028#[derive(Template)]
24031#[template(
24032 source = r##"
24033<!doctype html>
24034<html lang="en">
24035<head>
24036 <meta charset="utf-8">
24037 <meta name="viewport" content="width=device-width, initial-scale=1">
24038 <title>OxideSLOC — REST API Reference</title>
24039 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24040 <style nonce="{{ csp_nonce }}">
24041 :root {
24042 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
24043 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24044 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24045 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24046 --success:#16a34a;
24047 }
24048 body.dark-theme {
24049 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
24050 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
24051 }
24052 *{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;}
24053 .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);}
24054 .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;}
24055 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
24056 .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));}
24057 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
24058 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
24059 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
24060 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
24061 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24062 @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; } }
24063 .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;}
24064 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
24065 .nav-pill.active{background:rgba(255,255,255,0.22);}
24066 .nav-dropdown{position:relative;display:inline-flex;}
24067 .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;}
24068 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
24069 .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;}
24070 .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;}
24071 .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);}
24072 .nav-dropdown-menu a:last-child{border-bottom:none;}
24073 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
24074 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
24075 .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;}
24076 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24077 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24078 .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;}
24079 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24080 .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);}
24081 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
24082 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
24083 .settings-modal-body{padding:14px 16px 16px;}
24084 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24085 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24086 .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;}
24087 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24088 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24089 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24090 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24091 .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;}
24092 .tz-select:focus{border-color:var(--oxide);}
24093 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
24094 .page-header{margin-bottom:28px;}
24095 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
24096 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
24097 .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;}
24098 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
24099 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
24100 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
24101 .callout strong{font-weight:800;}
24102 .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;}
24103 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
24104 .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;}
24105 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
24106 .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;}
24107 body.dark-theme .base-url-value{color:var(--accent);}
24108 .section{margin-bottom:36px;}
24109 .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);}
24110 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
24111 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
24112 .ep-header:hover{background:var(--surface-2);}
24113 .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;}
24114 .method.get{background:#dcfce7;color:#166534;}
24115 .method.post{background:#dbeafe;color:#1e40af;}
24116 .method.delete{background:#fee2e2;color:#991b1b;}
24117 body.dark-theme .method.get{background:#14532d;color:#86efac;}
24118 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
24119 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
24120 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
24121 .ep-path .param{color:var(--oxide-2);}
24122 body.dark-theme .ep-path .param{color:var(--oxide);}
24123 .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;}
24124 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
24125 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
24126 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
24127 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
24128 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
24129 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
24130 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
24131 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
24132 .ep-card.open .chevron{transform:rotate(180deg);}
24133 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
24134 .ep-card.open .ep-body{display:block;}
24135 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
24136 .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;}
24137 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
24138 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
24139 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
24140 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
24141 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);}
24142 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
24143 table.params tr:last-child td{border-bottom:none;}
24144 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
24145 .pt-type{color:var(--muted-2);font-size:12px;}
24146 .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;}
24147 .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;}
24148 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
24149 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
24150 details.schema{margin-bottom:14px;}
24151 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;}
24152 details.schema summary:hover{color:var(--text);}
24153 .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;}
24154 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
24155 .curl-wrap{position:relative;}
24156 .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;}
24157 .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;}
24158 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
24159 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
24160 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
24161 .webhook-note a{color:var(--accent-2);text-decoration:none;}
24162 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24163 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24164 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24165 .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;}
24166 @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));}}
24167 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24168 .site-footer a{color:var(--muted);}
24169 </style>
24170</head>
24171<body>
24172 <div class="background-watermarks" aria-hidden="true">
24173 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24174 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24175 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24176 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24177 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24178 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24179 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24180 </div>
24181 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24182 <div class="top-nav">
24183 <div class="top-nav-inner">
24184 <a class="brand" href="/">
24185 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24186 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
24187 </a>
24188 <div class="nav-right">
24189 <a class="nav-pill" href="/">Home</a>
24190 <div class="nav-dropdown">
24191 <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>
24192 <div class="nav-dropdown-menu">
24193 <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>
24194 </div>
24195 </div>
24196 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24197 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24198 <div class="nav-dropdown">
24199 <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>
24200 <div class="nav-dropdown-menu">
24201 <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>
24202 </div>
24203 </div>
24204 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24205 <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>
24206 </button>
24207 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24208 <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>
24209 <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>
24210 </button>
24211 </div>
24212 </div>
24213 </div>
24214
24215 <div class="page">
24216 <div class="page-header">
24217 <h1 class="page-title">REST API Reference</h1>
24218 <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>
24219 </div>
24220
24221 {% if has_api_key %}
24222 <div class="callout key-set">
24223 <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>
24224 <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>
24225 </div>
24226 {% else %}
24227 <div class="callout no-key">
24228 <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>
24229 <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>
24230 </div>
24231 {% endif %}
24232
24233 <div class="base-url-bar">
24234 <span class="base-url-label">Base URL</span>
24235 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
24236 </div>
24237
24238 <!-- Health -->
24239 <div class="section">
24240 <h2 class="section-title">Health & Status</h2>
24241 <div class="ep-card">
24242 <div class="ep-header">
24243 <span class="method get">GET</span>
24244 <span class="ep-path">/healthz</span>
24245 <span class="auth-badge public">Public</span>
24246 <span class="ep-desc">Server liveness check</span>
24247 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24248 </div>
24249 <div class="ep-body">
24250 <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>
24251 <p class="params-heading">Response</p>
24252 <div class="schema-block">200 OK
24253Content-Type: text/plain
24254
24255ok</div>
24256 <p class="curl-heading">Example</p>
24257 <div class="curl-wrap">
24258 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
24259 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
24260 </div>
24261 </div>
24262 </div>
24263 </div>
24264
24265 <!-- Badges -->
24266 <div class="section">
24267 <h2 class="section-title">Badges</h2>
24268 <div class="ep-card">
24269 <div class="ep-header">
24270 <span class="method get">GET</span>
24271 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
24272 <span class="auth-badge public">Public</span>
24273 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
24274 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24275 </div>
24276 <div class="ep-body">
24277 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
24278 <p class="params-heading">Path Parameters</p>
24279 <table class="params">
24280 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24281 <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>
24282 </table>
24283 <p class="curl-heading">Example</p>
24284 <div class="curl-wrap">
24285 <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>
24286 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
24287 </div>
24288 </div>
24289 </div>
24290 </div>
24291
24292 <!-- Metrics -->
24293 <div class="section">
24294 <h2 class="section-title">Metrics</h2>
24295
24296 <div class="ep-card">
24297 <div class="ep-header">
24298 <span class="method get">GET</span>
24299 <span class="ep-path">/api/metrics/latest</span>
24300 <span class="auth-badge protected">Protected</span>
24301 <span class="ep-desc">Latest scan metrics (JSON)</span>
24302 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24303 </div>
24304 <div class="ep-body">
24305 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
24306 <details class="schema"><summary>Response schema</summary>
24307<div class="schema-block">{
24308 "run_id": string, // UUID
24309 "timestamp": string, // ISO-8601 UTC
24310 "project": string, // scanned root path
24311 "summary": {
24312 "files_analyzed": number,
24313 "files_skipped": number,
24314 "code_lines": number,
24315 "comment_lines": number,
24316 "blank_lines": number,
24317 "total_physical_lines": number,
24318 "functions": number,
24319 "classes": number,
24320 "variables": number,
24321 "imports": number
24322 },
24323 "languages": [
24324 { "name": string, "files": number, "code_lines": number,
24325 "comment_lines": number, "blank_lines": number,
24326 "functions": number, "classes": number,
24327 "variables": number, "imports": number }
24328 ]
24329}</div></details>
24330 <p class="curl-heading">Example</p>
24331 <div class="curl-wrap">
24332 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24333 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
24334 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
24335 </div>
24336 </div>
24337 </div>
24338
24339 <div class="ep-card">
24340 <div class="ep-header">
24341 <span class="method get">GET</span>
24342 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
24343 <span class="auth-badge protected">Protected</span>
24344 <span class="ep-desc">Metrics for a specific run</span>
24345 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24346 </div>
24347 <div class="ep-body">
24348 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
24349 <p class="params-heading">Path Parameters</p>
24350 <table class="params">
24351 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24352 <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>
24353 </table>
24354 <p class="curl-heading">Example</p>
24355 <div class="curl-wrap">
24356 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24357 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
24358 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
24359 </div>
24360 </div>
24361 </div>
24362
24363 <div class="ep-card">
24364 <div class="ep-header">
24365 <span class="method get">GET</span>
24366 <span class="ep-path">/api/metrics/history</span>
24367 <span class="auth-badge protected">Protected</span>
24368 <span class="ep-desc">Paginated scan history</span>
24369 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24370 </div>
24371 <div class="ep-body">
24372 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
24373 <p class="params-heading">Query Parameters</p>
24374 <table class="params">
24375 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24376 <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>
24377 <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>
24378 </table>
24379 <details class="schema"><summary>Response schema</summary>
24380<div class="schema-block">[{
24381 "run_id": string,
24382 "timestamp": string, // ISO-8601 UTC
24383 "commit": string | null,
24384 "branch": string | null,
24385 "tags": string[],
24386 "code_lines": number,
24387 "comment_lines": number,
24388 "blank_lines": number,
24389 "physical_lines": number,
24390 "files_analyzed": number,
24391 "project_label": string,
24392 "html_url": string | null
24393}]</div></details>
24394 <p class="curl-heading">Example</p>
24395 <div class="curl-wrap">
24396 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24397 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
24398 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
24399 </div>
24400 </div>
24401 </div>
24402
24403 <div class="ep-card">
24404 <div class="ep-header">
24405 <span class="method get">GET</span>
24406 <span class="ep-path">/api/project-history</span>
24407 <span class="auth-badge protected">Protected</span>
24408 <span class="ep-desc">Project-level scan summary</span>
24409 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24410 </div>
24411 <div class="ep-body">
24412 <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>
24413 <p class="params-heading">Query Parameters</p>
24414 <table class="params">
24415 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24416 <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>
24417 </table>
24418 <details class="schema"><summary>Response schema</summary>
24419<div class="schema-block">{
24420 "scan_count": number,
24421 "last_scan_id": string | null,
24422 "last_scan_timestamp": string | null, // ISO-8601
24423 "last_scan_code_lines": number | null,
24424 "last_git_branch": string | null,
24425 "last_git_commit": string | null
24426}</div></details>
24427 <p class="curl-heading">Example</p>
24428 <div class="curl-wrap">
24429 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24430 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
24431 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
24432 </div>
24433 </div>
24434 </div>
24435
24436 <div class="ep-card">
24437 <div class="ep-header">
24438 <span class="method get">GET</span>
24439 <span class="ep-path">/api/metrics/submodules</span>
24440 <span class="auth-badge protected">Protected</span>
24441 <span class="ep-desc">List known git submodules across scans</span>
24442 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24443 </div>
24444 <div class="ep-body">
24445 <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>
24446 <p class="params-heading">Query Parameters</p>
24447 <table class="params">
24448 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24449 <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>
24450 </table>
24451 <details class="schema"><summary>Response schema</summary>
24452<div class="schema-block">[{
24453 "name": string, // submodule name
24454 "relative_path": string // path relative to the project root
24455}]</div></details>
24456 <p class="curl-heading">Example</p>
24457 <div class="curl-wrap">
24458 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24459 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
24460 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
24461 </div>
24462 </div>
24463 </div>
24464 </div>
24465
24466 <!-- Async Run Status -->
24467 <div class="section">
24468 <h2 class="section-title">Async Run Status</h2>
24469
24470 <div class="ep-card">
24471 <div class="ep-header">
24472 <span class="method get">GET</span>
24473 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
24474 <span class="auth-badge protected">Protected</span>
24475 <span class="ep-desc">Poll scan completion</span>
24476 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24477 </div>
24478 <div class="ep-body">
24479 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
24480 <details class="schema"><summary>Response schema</summary>
24481<div class="schema-block">// Running
24482{ "state": "running", "elapsed_secs": number }
24483
24484// Complete
24485{ "state": "complete", "run_id": string }
24486
24487// Failed
24488{ "state": "failed", "message": string }</div></details>
24489 <p class="curl-heading">Example</p>
24490 <div class="curl-wrap">
24491 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24492 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
24493 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
24494 </div>
24495 </div>
24496 </div>
24497
24498 <div class="ep-card">
24499 <div class="ep-header">
24500 <span class="method get">GET</span>
24501 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
24502 <span class="auth-badge protected">Protected</span>
24503 <span class="ep-desc">Poll PDF generation readiness</span>
24504 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24505 </div>
24506 <div class="ep-body">
24507 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
24508 <details class="schema"><summary>Response schema</summary>
24509<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
24510 <p class="curl-heading">Example</p>
24511 <div class="curl-wrap">
24512 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24513 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
24514 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
24515 </div>
24516 </div>
24517 </div>
24518
24519 <div class="ep-card">
24520 <div class="ep-header">
24521 <span class="method post">POST</span>
24522 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
24523 <span class="auth-badge protected">Protected</span>
24524 <span class="ep-desc">Cancel a running scan</span>
24525 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24526 </div>
24527 <div class="ep-body">
24528 <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>
24529 <p class="curl-heading">Example</p>
24530 <div class="curl-wrap">
24531 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
24532 -H "Authorization: Bearer $SLOC_API_KEY" \
24533 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
24534 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
24535 </div>
24536 </div>
24537 </div>
24538 </div>
24539
24540 <!-- Run Management -->
24541 <div class="section">
24542 <h2 class="section-title">Run Management</h2>
24543
24544 <div class="ep-card">
24545 <div class="ep-header">
24546 <span class="method get">GET</span>
24547 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
24548 <span class="auth-badge protected">Protected</span>
24549 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
24550 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24551 </div>
24552 <div class="ep-body">
24553 <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>
24554 <p class="params-heading">Path Parameters</p>
24555 <table class="params">
24556 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24557 <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>
24558 </table>
24559 <details class="schema"><summary>Response</summary>
24560<div class="schema-block">200 OK — Content-Type: application/zip
24561Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
24562
24563404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
24564 <p class="curl-heading">Example</p>
24565 <div class="curl-wrap">
24566 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24567 -o run.zip \
24568 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
24569 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
24570 </div>
24571 </div>
24572 </div>
24573
24574 <div class="ep-card">
24575 <div class="ep-header">
24576 <span class="method delete">DELETE</span>
24577 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
24578 <span class="auth-badge protected">Protected</span>
24579 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
24580 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24581 </div>
24582 <div class="ep-body">
24583 <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>
24584 <p class="params-heading">Path Parameters</p>
24585 <table class="params">
24586 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24587 <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>
24588 </table>
24589 <details class="schema"><summary>Response</summary>
24590<div class="schema-block">204 No Content — run successfully deleted
24591
24592500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
24593 <p class="curl-heading">Example</p>
24594 <div class="curl-wrap">
24595 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
24596 -H "Authorization: Bearer $SLOC_API_KEY" \
24597 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
24598 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
24599 </div>
24600 </div>
24601 </div>
24602
24603 <div class="ep-card">
24604 <div class="ep-header">
24605 <span class="method post">POST</span>
24606 <span class="ep-path">/api/runs/cleanup</span>
24607 <span class="auth-badge protected">Protected</span>
24608 <span class="ep-desc">Bulk delete runs older than N days</span>
24609 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24610 </div>
24611 <div class="ep-body">
24612 <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>
24613 <p class="params-heading">Request Body (application/json)</p>
24614 <table class="params">
24615 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24616 <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>
24617 </table>
24618 <details class="schema"><summary>Response schema</summary>
24619<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
24620 <p class="curl-heading">Example — delete runs older than 60 days</p>
24621 <div class="curl-wrap">
24622 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
24623 -H "Authorization: Bearer $SLOC_API_KEY" \
24624 -H "Content-Type: application/json" \
24625 -d '{"older_than_days":60}' \
24626 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
24627 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
24628 </div>
24629 </div>
24630 </div>
24631 </div>
24632
24633 <!-- Retention Policy -->
24634 <div class="section">
24635 <h2 class="section-title">Retention Policy</h2>
24636
24637 <div class="ep-card">
24638 <div class="ep-header">
24639 <span class="method get">GET</span>
24640 <span class="ep-path">/api/cleanup-policy</span>
24641 <span class="auth-badge protected">Protected</span>
24642 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
24643 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24644 </div>
24645 <div class="ep-body">
24646 <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>
24647 <details class="schema"><summary>Response schema</summary>
24648<div class="schema-block">{
24649 "policy": {
24650 "enabled": boolean,
24651 "max_age_days": number | null, // delete runs older than N days
24652 "max_run_count": number | null, // keep only the N most recent runs
24653 "interval_hours": number // hours between background passes
24654 } | null,
24655 "last_run_at": string | null, // ISO-8601 UTC timestamp
24656 "last_run_deleted": number | null // runs deleted in last pass
24657}</div></details>
24658 <p class="curl-heading">Example</p>
24659 <div class="curl-wrap">
24660 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24661 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24662 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
24663 </div>
24664 </div>
24665 </div>
24666
24667 <div class="ep-card">
24668 <div class="ep-header">
24669 <span class="method post">POST</span>
24670 <span class="ep-path">/api/cleanup-policy</span>
24671 <span class="auth-badge protected">Protected</span>
24672 <span class="ep-desc">Save or update the retention policy</span>
24673 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24674 </div>
24675 <div class="ep-body">
24676 <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>
24677 <p class="params-heading">Request Body (application/json)</p>
24678 <table class="params">
24679 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24680 <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>
24681 <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>
24682 <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>
24683 <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>
24684 </table>
24685 <details class="schema"><summary>Response</summary>
24686<div class="schema-block">204 No Content — policy saved and task (re)started
24687
24688500 Internal Server Error — { "error": string }</div></details>
24689 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
24690 <div class="curl-wrap">
24691 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
24692 -H "Authorization: Bearer $SLOC_API_KEY" \
24693 -H "Content-Type: application/json" \
24694 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
24695 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24696 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
24697 </div>
24698 </div>
24699 </div>
24700
24701 <div class="ep-card">
24702 <div class="ep-header">
24703 <span class="method post">POST</span>
24704 <span class="ep-path">/api/cleanup-policy/run-now</span>
24705 <span class="auth-badge protected">Protected</span>
24706 <span class="ep-desc">Trigger an immediate cleanup pass</span>
24707 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24708 </div>
24709 <div class="ep-body">
24710 <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>
24711 <details class="schema"><summary>Response schema</summary>
24712<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
24713 <p class="curl-heading">Example</p>
24714 <div class="curl-wrap">
24715 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
24716 -H "Authorization: Bearer $SLOC_API_KEY" \
24717 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
24718 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
24719 </div>
24720 </div>
24721 </div>
24722
24723 <div class="ep-card">
24724 <div class="ep-header">
24725 <span class="method delete">DELETE</span>
24726 <span class="ep-path">/api/cleanup-policy</span>
24727 <span class="auth-badge protected">Protected</span>
24728 <span class="ep-desc">Remove the retention policy and stop the background task</span>
24729 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24730 </div>
24731 <div class="ep-body">
24732 <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>
24733 <details class="schema"><summary>Response</summary>
24734<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
24735 <p class="curl-heading">Example</p>
24736 <div class="curl-wrap">
24737 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
24738 -H "Authorization: Bearer $SLOC_API_KEY" \
24739 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24740 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
24741 </div>
24742 </div>
24743 </div>
24744 </div>
24745
24746 <!-- Scan Profiles -->
24747 <div class="section">
24748 <h2 class="section-title">Scan Profiles</h2>
24749
24750 <div class="ep-card">
24751 <div class="ep-header">
24752 <span class="method get">GET</span>
24753 <span class="ep-path">/api/scan-profiles</span>
24754 <span class="auth-badge protected">Protected</span>
24755 <span class="ep-desc">List saved scan profiles</span>
24756 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24757 </div>
24758 <div class="ep-body">
24759 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
24760 <details class="schema"><summary>Response schema</summary>
24761<div class="schema-block">{
24762 "profiles": [{
24763 "id": string, // UUID
24764 "name": string,
24765 "created_at": string, // ISO-8601
24766 "params": object
24767 }]
24768}</div></details>
24769 <p class="curl-heading">Example</p>
24770 <div class="curl-wrap">
24771 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24772 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24773 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
24774 </div>
24775 </div>
24776 </div>
24777
24778 <div class="ep-card">
24779 <div class="ep-header">
24780 <span class="method post">POST</span>
24781 <span class="ep-path">/api/scan-profiles</span>
24782 <span class="auth-badge protected">Protected</span>
24783 <span class="ep-desc">Save a scan profile</span>
24784 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24785 </div>
24786 <div class="ep-body">
24787 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
24788 <p class="params-heading">Request Body (application/json)</p>
24789 <table class="params">
24790 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24791 <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>
24792 <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>
24793 </table>
24794 <details class="schema"><summary>Response schema</summary>
24795<div class="schema-block">{ "ok": true }</div></details>
24796 <p class="curl-heading">Example</p>
24797 <div class="curl-wrap">
24798 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
24799 -H "Authorization: Bearer $SLOC_API_KEY" \
24800 -H "Content-Type: application/json" \
24801 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
24802 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24803 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
24804 </div>
24805 </div>
24806 </div>
24807
24808 <div class="ep-card">
24809 <div class="ep-header">
24810 <span class="method delete">DELETE</span>
24811 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
24812 <span class="auth-badge protected">Protected</span>
24813 <span class="ep-desc">Delete a scan profile</span>
24814 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24815 </div>
24816 <div class="ep-body">
24817 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
24818 <p class="params-heading">Path Parameters</p>
24819 <table class="params">
24820 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24821 <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>
24822 </table>
24823 <details class="schema"><summary>Response schema</summary>
24824<div class="schema-block">{ "ok": true }</div></details>
24825 <p class="curl-heading">Example</p>
24826 <div class="curl-wrap">
24827 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
24828 -H "Authorization: Bearer $SLOC_API_KEY" \
24829 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
24830 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
24831 </div>
24832 </div>
24833 </div>
24834 </div>
24835
24836 <!-- Scheduled Scans -->
24837 <div class="section">
24838 <h2 class="section-title">Scheduled Scans</h2>
24839
24840 <div class="ep-card">
24841 <div class="ep-header">
24842 <span class="method get">GET</span>
24843 <span class="ep-path">/api/schedules</span>
24844 <span class="auth-badge protected">Protected</span>
24845 <span class="ep-desc">List configured schedules</span>
24846 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24847 </div>
24848 <div class="ep-body">
24849 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
24850 <p class="curl-heading">Example</p>
24851 <div class="curl-wrap">
24852 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24853 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24854 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
24855 </div>
24856 </div>
24857 </div>
24858
24859 <div class="ep-card">
24860 <div class="ep-header">
24861 <span class="method post">POST</span>
24862 <span class="ep-path">/api/schedules</span>
24863 <span class="auth-badge protected">Protected</span>
24864 <span class="ep-desc">Create a schedule</span>
24865 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24866 </div>
24867 <div class="ep-body">
24868 <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>
24869 <p class="curl-heading">Example</p>
24870 <div class="curl-wrap">
24871 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
24872 -H "Authorization: Bearer $SLOC_API_KEY" \
24873 -H "Content-Type: application/json" \
24874 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
24875 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24876 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
24877 </div>
24878 </div>
24879 </div>
24880
24881 <div class="ep-card">
24882 <div class="ep-header">
24883 <span class="method delete">DELETE</span>
24884 <span class="ep-path">/api/schedules</span>
24885 <span class="auth-badge protected">Protected</span>
24886 <span class="ep-desc">Delete a schedule</span>
24887 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24888 </div>
24889 <div class="ep-body">
24890 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
24891 <p class="curl-heading">Example</p>
24892 <div class="curl-wrap">
24893 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
24894 -H "Authorization: Bearer $SLOC_API_KEY" \
24895 -H "Content-Type: application/json" \
24896 -d '{"id":"<schedule_id>"}' \
24897 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24898 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
24899 </div>
24900 </div>
24901 </div>
24902 </div>
24903
24904 <!-- Git Browser -->
24905 <div class="section">
24906 <h2 class="section-title">Git Browser</h2>
24907
24908 <div class="ep-card">
24909 <div class="ep-header">
24910 <span class="method get">GET</span>
24911 <span class="ep-path">/api/git/refs</span>
24912 <span class="auth-badge protected">Protected</span>
24913 <span class="ep-desc">List git refs for a repository</span>
24914 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24915 </div>
24916 <div class="ep-body">
24917 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
24918 <p class="params-heading">Query Parameters</p>
24919 <table class="params">
24920 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24921 <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>
24922 </table>
24923 <p class="curl-heading">Example</p>
24924 <div class="curl-wrap">
24925 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24926 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
24927 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
24928 </div>
24929 </div>
24930 </div>
24931
24932 <div class="ep-card">
24933 <div class="ep-header">
24934 <span class="method get">GET</span>
24935 <span class="ep-path">/api/git/scan-ref</span>
24936 <span class="auth-badge protected">Protected</span>
24937 <span class="ep-desc">SLOC-scan a specific git ref</span>
24938 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24939 </div>
24940 <div class="ep-body">
24941 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
24942 <p class="params-heading">Query Parameters</p>
24943 <table class="params">
24944 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24945 <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>
24946 <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>
24947 </table>
24948 <p class="curl-heading">Example</p>
24949 <div class="curl-wrap">
24950 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24951 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
24952 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
24953 </div>
24954 </div>
24955 </div>
24956
24957 <div class="ep-card">
24958 <div class="ep-header">
24959 <span class="method get">GET</span>
24960 <span class="ep-path">/api/git/compare-refs</span>
24961 <span class="auth-badge protected">Protected</span>
24962 <span class="ep-desc">Compare SLOC across two git refs</span>
24963 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24964 </div>
24965 <div class="ep-body">
24966 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
24967 <p class="params-heading">Query Parameters</p>
24968 <table class="params">
24969 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24970 <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>
24971 <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>
24972 <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>
24973 </table>
24974 <p class="curl-heading">Example</p>
24975 <div class="curl-wrap">
24976 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24977 "<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>
24978 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
24979 </div>
24980 </div>
24981 </div>
24982 </div>
24983
24984 <!-- Webhooks -->
24985 <div class="section">
24986 <h2 class="section-title">Webhooks</h2>
24987 <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>
24988
24989 <div class="ep-card">
24990 <div class="ep-header">
24991 <span class="method post">POST</span>
24992 <span class="ep-path">/webhooks/github</span>
24993 <span class="auth-badge hmac">HMAC</span>
24994 <span class="ep-desc">GitHub push event receiver</span>
24995 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24996 </div>
24997 <div class="ep-body">
24998 <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>
24999 <p class="params-heading">Required Headers</p>
25000 <table class="params">
25001 <tr><th>Header</th><th>Value</th></tr>
25002 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
25003 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
25004 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
25005 </table>
25006 </div>
25007 </div>
25008
25009 <div class="ep-card">
25010 <div class="ep-header">
25011 <span class="method post">POST</span>
25012 <span class="ep-path">/webhooks/gitlab</span>
25013 <span class="auth-badge hmac">HMAC</span>
25014 <span class="ep-desc">GitLab push event receiver</span>
25015 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25016 </div>
25017 <div class="ep-body">
25018 <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>
25019 <p class="params-heading">Required Headers</p>
25020 <table class="params">
25021 <tr><th>Header</th><th>Value</th></tr>
25022 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
25023 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
25024 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
25025 </table>
25026 </div>
25027 </div>
25028
25029 <div class="ep-card">
25030 <div class="ep-header">
25031 <span class="method post">POST</span>
25032 <span class="ep-path">/webhooks/bitbucket</span>
25033 <span class="auth-badge hmac">HMAC</span>
25034 <span class="ep-desc">Bitbucket push event receiver</span>
25035 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25036 </div>
25037 <div class="ep-body">
25038 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
25039 <p class="params-heading">Required Headers</p>
25040 <table class="params">
25041 <tr><th>Header</th><th>Value</th></tr>
25042 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
25043 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
25044 </table>
25045 </div>
25046 </div>
25047 </div>
25048
25049 <!-- Config -->
25050 <div class="section">
25051 <h2 class="section-title">Config Import / Export</h2>
25052
25053 <div class="ep-card">
25054 <div class="ep-header">
25055 <span class="method get">GET</span>
25056 <span class="ep-path">/export-config</span>
25057 <span class="auth-badge protected">Protected</span>
25058 <span class="ep-desc">Export server configuration as JSON</span>
25059 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25060 </div>
25061 <div class="ep-body">
25062 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
25063 <p class="curl-heading">Example</p>
25064 <div class="curl-wrap">
25065 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25066 -o config.json \
25067 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
25068 <button class="curl-copy-btn" data-target="c-export">Copy</button>
25069 </div>
25070 </div>
25071 </div>
25072
25073 <div class="ep-card">
25074 <div class="ep-header">
25075 <span class="method post">POST</span>
25076 <span class="ep-path">/import-config</span>
25077 <span class="auth-badge protected">Protected</span>
25078 <span class="ep-desc">Import server configuration</span>
25079 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25080 </div>
25081 <div class="ep-body">
25082 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
25083 <p class="curl-heading">Example</p>
25084 <div class="curl-wrap">
25085 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
25086 -H "Authorization: Bearer $SLOC_API_KEY" \
25087 -H "Content-Type: application/json" \
25088 -d @config.json \
25089 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
25090 <button class="curl-copy-btn" data-target="c-import">Copy</button>
25091 </div>
25092 </div>
25093 </div>
25094 </div>
25095
25096 <!-- CI Ingest -->
25097 <div class="section">
25098 <h2 class="section-title">CI Ingest</h2>
25099
25100 <div class="ep-card">
25101 <div class="ep-header">
25102 <span class="method post">POST</span>
25103 <span class="ep-path">/api/ingest</span>
25104 <span class="auth-badge protected">Protected</span>
25105 <span class="ep-desc">Push a pre-computed scan result from CI</span>
25106 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25107 </div>
25108 <div class="ep-body">
25109 <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>
25110 <p class="params-heading">Query Parameters</p>
25111 <table class="params">
25112 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25113 <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>
25114 </table>
25115 <p class="params-heading">Request Body (application/json)</p>
25116 <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>
25117 <details class="schema"><summary>Response schema</summary>
25118<div class="schema-block">// 201 Created
25119{
25120 "run_id": string, // UUID of the ingested run
25121 "view_url": string // relative URL to the report page
25122}</div></details>
25123 <p class="curl-heading">Example</p>
25124 <div class="curl-wrap">
25125 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
25126 -H "Authorization: Bearer $SLOC_API_KEY" \
25127 -H "Content-Type: application/json" \
25128 -d @result.json \
25129 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
25130 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
25131 </div>
25132 </div>
25133 </div>
25134 </div>
25135
25136 <!-- Artifact Download -->
25137 <div class="section">
25138 <h2 class="section-title">Artifact Download</h2>
25139
25140 <div class="ep-card">
25141 <div class="ep-header">
25142 <span class="method get">GET</span>
25143 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
25144 <span class="auth-badge protected">Protected</span>
25145 <span class="ep-desc">Download or view a scan artifact</span>
25146 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25147 </div>
25148 <div class="ep-body">
25149 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
25150 <p class="params-heading">Path Parameters</p>
25151 <table class="params">
25152 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25153 <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>
25154 <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>
25155 </table>
25156 <p class="params-heading">Query Parameters</p>
25157 <table class="params">
25158 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25159 <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>
25160 </table>
25161 <p class="curl-heading">Example — download JSON result</p>
25162 <div class="curl-wrap">
25163 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25164 -o result.json \
25165 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
25166 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
25167 </div>
25168 </div>
25169 </div>
25170 </div>
25171
25172 <!-- Embed Widget -->
25173 <div class="section">
25174 <h2 class="section-title">Embed Widget</h2>
25175
25176 <div class="ep-card">
25177 <div class="ep-header">
25178 <span class="method get">GET</span>
25179 <span class="ep-path">/embed/summary</span>
25180 <span class="auth-badge protected">Protected</span>
25181 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
25182 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25183 </div>
25184 <div class="ep-body">
25185 <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>
25186 <p class="params-heading">Query Parameters</p>
25187 <table class="params">
25188 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25189 <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>
25190 <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>
25191 </table>
25192 <p class="curl-heading">Example</p>
25193 <div class="curl-wrap">
25194 <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"
25195 width="460" height="260" style="border:none"></iframe></pre>
25196 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
25197 </div>
25198 </div>
25199 </div>
25200 </div>
25201
25202 <!-- Confluence Integration -->
25203 <div class="section">
25204 <h2 class="section-title">Confluence Integration</h2>
25205
25206 <div class="ep-card">
25207 <div class="ep-header">
25208 <span class="method get">GET</span>
25209 <span class="ep-path">/api/confluence/config</span>
25210 <span class="auth-badge protected">Protected</span>
25211 <span class="ep-desc">Get current Confluence configuration</span>
25212 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25213 </div>
25214 <div class="ep-body">
25215 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
25216 <details class="schema"><summary>Response schema</summary>
25217<div class="schema-block">{
25218 "configured": boolean,
25219 "tier": "cloud" | "server",
25220 "base_url": string,
25221 "username": string,
25222 "api_token_set": boolean,
25223 "space_key": string,
25224 "parent_page_id": string | null,
25225 "schedule_auto_post": { "<schedule_id>": boolean }
25226}</div></details>
25227 <p class="curl-heading">Example</p>
25228 <div class="curl-wrap">
25229 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25230 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
25231 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
25232 </div>
25233 </div>
25234 </div>
25235
25236 <div class="ep-card">
25237 <div class="ep-header">
25238 <span class="method post">POST</span>
25239 <span class="ep-path">/api/confluence/config</span>
25240 <span class="auth-badge protected">Protected</span>
25241 <span class="ep-desc">Save Confluence configuration</span>
25242 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25243 </div>
25244 <div class="ep-body">
25245 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
25246 <p class="params-heading">Request Body (application/json)</p>
25247 <table class="params">
25248 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25249 <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>
25250 <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>
25251 <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>
25252 <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>
25253 <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>
25254 <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>
25255 <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>
25256 </table>
25257 <details class="schema"><summary>Response schema</summary>
25258<div class="schema-block">{ "ok": true }</div></details>
25259 <p class="curl-heading">Example</p>
25260 <div class="curl-wrap">
25261 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
25262 -H "Authorization: Bearer $SLOC_API_KEY" \
25263 -H "Content-Type: application/json" \
25264 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
25265 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
25266 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
25267 </div>
25268 </div>
25269 </div>
25270
25271 <div class="ep-card">
25272 <div class="ep-header">
25273 <span class="method post">POST</span>
25274 <span class="ep-path">/api/confluence/test</span>
25275 <span class="auth-badge protected">Protected</span>
25276 <span class="ep-desc">Test Confluence connection</span>
25277 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25278 </div>
25279 <div class="ep-body">
25280 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
25281 <details class="schema"><summary>Response schema</summary>
25282<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
25283 <p class="curl-heading">Example</p>
25284 <div class="curl-wrap">
25285 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
25286 -H "Authorization: Bearer $SLOC_API_KEY" \
25287 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
25288 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
25289 </div>
25290 </div>
25291 </div>
25292
25293 <div class="ep-card">
25294 <div class="ep-header">
25295 <span class="method post">POST</span>
25296 <span class="ep-path">/api/confluence/post</span>
25297 <span class="auth-badge protected">Protected</span>
25298 <span class="ep-desc">Publish a scan report to Confluence</span>
25299 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25300 </div>
25301 <div class="ep-body">
25302 <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>
25303 <p class="params-heading">Request Body (application/json)</p>
25304 <table class="params">
25305 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25306 <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>
25307 <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>
25308 <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>
25309 </table>
25310 <details class="schema"><summary>Response schema</summary>
25311<div class="schema-block">// 200 OK
25312{ "ok": true, "page_id": string }
25313
25314// 400 / 502 on error
25315{ "ok": false, "error": string }</div></details>
25316 <p class="curl-heading">Example</p>
25317 <div class="curl-wrap">
25318 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
25319 -H "Authorization: Bearer $SLOC_API_KEY" \
25320 -H "Content-Type: application/json" \
25321 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
25322 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
25323 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
25324 </div>
25325 </div>
25326 </div>
25327
25328 <div class="ep-card">
25329 <div class="ep-header">
25330 <span class="method get">GET</span>
25331 <span class="ep-path">/api/confluence/wiki-markup</span>
25332 <span class="auth-badge protected">Protected</span>
25333 <span class="ep-desc">Get Confluence wiki markup for a run</span>
25334 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25335 </div>
25336 <div class="ep-body">
25337 <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>
25338 <p class="params-heading">Query Parameters</p>
25339 <table class="params">
25340 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25341 <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>
25342 </table>
25343 <p class="curl-heading">Example</p>
25344 <div class="curl-wrap">
25345 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25346 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
25347 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
25348 </div>
25349 </div>
25350 </div>
25351 </div>
25352
25353 <!-- Authentication -->
25354 <div class="section">
25355 <h2 class="section-title">Authentication</h2>
25356 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
25357
25358 <div class="ep-card">
25359 <div class="ep-header">
25360 <span class="method get">GET</span>
25361 <span class="ep-path">/auth/login</span>
25362 <span class="auth-badge public">Public</span>
25363 <span class="ep-desc">Login page</span>
25364 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25365 </div>
25366 <div class="ep-body">
25367 <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>
25368 <p class="params-heading">Query Parameters</p>
25369 <table class="params">
25370 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25371 <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>
25372 <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>
25373 </table>
25374 </div>
25375 </div>
25376
25377 <div class="ep-card">
25378 <div class="ep-header">
25379 <span class="method post">POST</span>
25380 <span class="ep-path">/auth/login</span>
25381 <span class="auth-badge public">Public</span>
25382 <span class="ep-desc">Submit credentials and get a session cookie</span>
25383 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25384 </div>
25385 <div class="ep-body">
25386 <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>
25387 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
25388 <table class="params">
25389 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25390 <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>
25391 <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>
25392 </table>
25393 <p class="curl-heading">Example</p>
25394 <div class="curl-wrap">
25395 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
25396 -d "key=$SLOC_API_KEY&next=/" \
25397 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
25398 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
25399 </div>
25400 </div>
25401 </div>
25402 </div>
25403
25404 <!-- Coverage Suggestion -->
25405 <div class="section">
25406 <h2 class="section-title">Coverage Suggestion</h2>
25407
25408 <div class="ep-card">
25409 <div class="ep-header">
25410 <span class="method get">GET</span>
25411 <span class="ep-path">/api/suggest-coverage</span>
25412 <span class="auth-badge protected">Protected</span>
25413 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
25414 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25415 </div>
25416 <div class="ep-body">
25417 <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML) and returns the first one found, along with a hint for how to generate it if not present.</p>
25418 <p class="params-heading">Query Parameters</p>
25419 <table class="params">
25420 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25421 <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>
25422 </table>
25423 <details class="schema"><summary>Response schema</summary>
25424<div class="schema-block">{
25425 "found": string | null, // absolute path to the coverage file, if detected
25426 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
25427 "hint": string | null // shell command to generate coverage if not found
25428}</div></details>
25429 <p class="curl-heading">Example</p>
25430 <div class="curl-wrap">
25431 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25432 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
25433 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
25434 </div>
25435 </div>
25436 </div>
25437 </div>
25438
25439 </div>
25440
25441 <footer class="site-footer">
25442 local code analysis - metrics, history and reports
25443 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25444 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25445 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25446 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25447 · <a href="/api-docs" rel="noopener">REST API</a>
25448 </footer>
25449
25450 <script nonce="{{ csp_nonce }}">
25451 (function () {
25452 var base = window.location.origin;
25453 document.getElementById('base-url').textContent = base;
25454 document.querySelectorAll('.base-url-slot').forEach(function (el) {
25455 el.textContent = base;
25456 });
25457
25458 document.querySelectorAll('.ep-header').forEach(function (hdr) {
25459 hdr.addEventListener('click', function () {
25460 hdr.closest('.ep-card').classList.toggle('open');
25461 });
25462 });
25463
25464 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
25465 btn.addEventListener('click', function () {
25466 var targetId = btn.dataset.target;
25467 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
25468 if (!pre) return;
25469 navigator.clipboard.writeText(pre.textContent).then(function () {
25470 btn.textContent = 'Copied!';
25471 btn.classList.add('copied');
25472 setTimeout(function () {
25473 btn.textContent = 'Copy';
25474 btn.classList.remove('copied');
25475 }, 2000);
25476 });
25477 });
25478 });
25479
25480 var storageKey = 'oxide-sloc-theme';
25481 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
25482 var themeBtn = document.getElementById('theme-toggle');
25483 if (themeBtn) {
25484 themeBtn.addEventListener('click', function () {
25485 var dark = document.body.classList.toggle('dark-theme');
25486 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
25487 });
25488 }
25489 (function() {
25490 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'}];
25491 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);});}
25492 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25493 var btn=document.getElementById('settings-btn');if(!btn)return;
25494 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25495 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>';
25496 document.body.appendChild(m);
25497 var g=document.getElementById('scheme-grid');
25498 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);});
25499 var cl=document.getElementById('settings-close');
25500 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);
25501 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');});
25502 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25503 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25504 })();
25505 (function randomizeWatermarks() {
25506 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25507 if (!wms.length) return;
25508 var placed = [];
25509 function tooClose(top, left) {
25510 for (var i = 0; i < placed.length; i++) {
25511 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
25512 if (dt < 16 && dl < 12) return true;
25513 }
25514 return false;
25515 }
25516 function pick(leftBand) {
25517 for (var attempt = 0; attempt < 50; attempt++) {
25518 var top = Math.random() * 88 + 2;
25519 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25520 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25521 }
25522 var top = Math.random() * 88 + 2;
25523 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25524 placed.push([top, left]); return [top, left];
25525 }
25526 var half = Math.floor(wms.length / 2);
25527 wms.forEach(function (img, i) {
25528 var pos = pick(i < half);
25529 var size = Math.floor(Math.random() * 100 + 120);
25530 var rot = (Math.random() * 360).toFixed(1);
25531 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
25532 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;
25533 });
25534 })();
25535 (function spawnCodeParticles() {
25536 var container = document.getElementById('code-particles');
25537 if (!container) return;
25538 var snippets = [
25539 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
25540 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
25541 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
25542 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
25543 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
25544 ];
25545 var count = 38;
25546 for (var i = 0; i < count; i++) {
25547 (function(idx) {
25548 var el = document.createElement('span');
25549 el.className = 'code-particle';
25550 el.textContent = snippets[idx % snippets.length];
25551 var left = Math.random() * 94 + 2;
25552 var top = Math.random() * 88 + 6;
25553 var dur = (Math.random() * 10 + 9).toFixed(1);
25554 var delay = (Math.random() * 18).toFixed(1);
25555 var rot = (Math.random() * 26 - 13).toFixed(1);
25556 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25557 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
25558 container.appendChild(el);
25559 })(i);
25560 }
25561 })();
25562 }());
25563 </script>
25564</body>
25565</html>
25566"##,
25567 ext = "html"
25568)]
25569struct ApiDocsTemplate {
25570 has_api_key: bool,
25571 csp_nonce: String,
25572 version: &'static str,
25573}
25574
25575#[cfg(test)]
25576mod form_config_tests {
25577 use super::*;
25578 use sloc_config::{
25579 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
25580 };
25581
25582 fn blank_form() -> AnalyzeForm {
25583 AnalyzeForm {
25584 path: ".".to_string(),
25585 git_repo: None,
25586 git_ref: None,
25587 mixed_line_policy: None,
25588 python_docstrings_as_comments: None,
25589 generated_file_detection: None,
25590 minified_file_detection: None,
25591 vendor_directory_detection: None,
25592 include_lockfiles: None,
25593 binary_file_behavior: None,
25594 output_dir: None,
25595 report_title: None,
25596 report_header_footer: None,
25597 include_globs: None,
25598 exclude_globs: None,
25599 submodule_breakdown: None,
25600 coverage_file: None,
25601 continuation_line_policy: None,
25602 blank_in_block_comment_policy: None,
25603 count_compiler_directives: None,
25604 style_col_threshold: None,
25605 style_analysis_enabled: None,
25606 style_score_threshold: None,
25607 style_lang_scope: None,
25608 }
25609 }
25610
25611 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
25612 let mut cfg = sloc_config::AppConfig::default();
25613 apply_form_to_config(&mut cfg, form);
25614 cfg
25615 }
25616
25617 #[test]
25620 fn python_docstrings_false_when_unchecked() {
25621 let cfg = apply(&blank_form());
25623 assert!(
25624 !cfg.analysis.python_docstrings_as_comments,
25625 "absent python_docstrings_as_comments must map to false"
25626 );
25627 }
25628
25629 #[test]
25630 fn python_docstrings_true_when_checked() {
25631 let mut form = blank_form();
25633 form.python_docstrings_as_comments = Some("on".to_string());
25634 let cfg = apply(&form);
25635 assert!(cfg.analysis.python_docstrings_as_comments);
25636 }
25637
25638 #[test]
25639 fn python_docstrings_true_for_any_non_none_value() {
25640 let mut form = blank_form();
25642 form.python_docstrings_as_comments = Some("true".to_string());
25643 assert!(apply(&form).analysis.python_docstrings_as_comments);
25644 }
25645
25646 #[test]
25649 fn submodule_breakdown_false_when_unchecked() {
25650 let cfg = apply(&blank_form());
25651 assert!(
25652 !cfg.discovery.submodule_breakdown,
25653 "absent submodule_breakdown must map to false"
25654 );
25655 }
25656
25657 #[test]
25658 fn submodule_breakdown_true_when_value_enabled() {
25659 let mut form = blank_form();
25660 form.submodule_breakdown = Some("enabled".to_string());
25661 assert!(apply(&form).discovery.submodule_breakdown);
25662 }
25663
25664 #[test]
25665 fn submodule_breakdown_false_for_wrong_value() {
25666 let mut form = blank_form();
25668 form.submodule_breakdown = Some("on".to_string());
25669 assert!(
25670 !apply(&form).discovery.submodule_breakdown,
25671 "submodule_breakdown only becomes true for the exact value 'enabled'"
25672 );
25673 }
25674
25675 #[test]
25678 fn generated_detection_true_when_enabled() {
25679 let mut form = blank_form();
25680 form.generated_file_detection = Some("enabled".to_string());
25681 assert!(apply(&form).analysis.generated_file_detection);
25682 }
25683
25684 #[test]
25685 fn generated_detection_false_when_disabled() {
25686 let mut form = blank_form();
25687 form.generated_file_detection = Some("disabled".to_string());
25688 assert!(!apply(&form).analysis.generated_file_detection);
25689 }
25690
25691 #[test]
25692 fn generated_detection_true_when_absent() {
25693 assert!(
25695 apply(&blank_form()).analysis.generated_file_detection,
25696 "absent field must default to true (detection on)"
25697 );
25698 }
25699
25700 #[test]
25703 fn minified_detection_false_when_disabled() {
25704 let mut form = blank_form();
25705 form.minified_file_detection = Some("disabled".to_string());
25706 assert!(!apply(&form).analysis.minified_file_detection);
25707 }
25708
25709 #[test]
25710 fn minified_detection_true_when_enabled() {
25711 let mut form = blank_form();
25712 form.minified_file_detection = Some("enabled".to_string());
25713 assert!(apply(&form).analysis.minified_file_detection);
25714 }
25715
25716 #[test]
25717 fn minified_detection_true_when_absent() {
25718 assert!(apply(&blank_form()).analysis.minified_file_detection);
25719 }
25720
25721 #[test]
25724 fn vendor_detection_false_when_disabled() {
25725 let mut form = blank_form();
25726 form.vendor_directory_detection = Some("disabled".to_string());
25727 assert!(!apply(&form).analysis.vendor_directory_detection);
25728 }
25729
25730 #[test]
25731 fn vendor_detection_true_when_enabled() {
25732 let mut form = blank_form();
25733 form.vendor_directory_detection = Some("enabled".to_string());
25734 assert!(apply(&form).analysis.vendor_directory_detection);
25735 }
25736
25737 #[test]
25738 fn vendor_detection_true_when_absent() {
25739 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
25740 }
25741
25742 #[test]
25745 fn lockfiles_false_when_absent() {
25746 assert!(!apply(&blank_form()).analysis.include_lockfiles);
25748 }
25749
25750 #[test]
25751 fn lockfiles_false_when_disabled() {
25752 let mut form = blank_form();
25753 form.include_lockfiles = Some("disabled".to_string());
25754 assert!(!apply(&form).analysis.include_lockfiles);
25755 }
25756
25757 #[test]
25758 fn lockfiles_true_when_enabled() {
25759 let mut form = blank_form();
25760 form.include_lockfiles = Some("enabled".to_string());
25761 assert!(apply(&form).analysis.include_lockfiles);
25762 }
25763
25764 #[test]
25767 fn compiler_directives_true_when_absent() {
25768 assert!(
25769 apply(&blank_form()).analysis.count_compiler_directives,
25770 "absent count_compiler_directives must default to true"
25771 );
25772 }
25773
25774 #[test]
25775 fn compiler_directives_true_when_enabled() {
25776 let mut form = blank_form();
25777 form.count_compiler_directives = Some("enabled".to_string());
25778 assert!(apply(&form).analysis.count_compiler_directives);
25779 }
25780
25781 #[test]
25782 fn compiler_directives_false_when_disabled() {
25783 let mut form = blank_form();
25784 form.count_compiler_directives = Some("disabled".to_string());
25785 assert!(!apply(&form).analysis.count_compiler_directives);
25786 }
25787
25788 #[test]
25791 fn mixed_policy_unchanged_when_absent() {
25792 assert_eq!(
25794 apply(&blank_form()).analysis.mixed_line_policy,
25795 MixedLinePolicy::CodeOnly
25796 );
25797 }
25798
25799 #[test]
25800 fn mixed_policy_code_only() {
25801 let mut form = blank_form();
25802 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
25803 assert_eq!(
25804 apply(&form).analysis.mixed_line_policy,
25805 MixedLinePolicy::CodeOnly
25806 );
25807 }
25808
25809 #[test]
25810 fn mixed_policy_code_and_comment() {
25811 let mut form = blank_form();
25812 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
25813 assert_eq!(
25814 apply(&form).analysis.mixed_line_policy,
25815 MixedLinePolicy::CodeAndComment
25816 );
25817 }
25818
25819 #[test]
25820 fn mixed_policy_comment_only() {
25821 let mut form = blank_form();
25822 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
25823 assert_eq!(
25824 apply(&form).analysis.mixed_line_policy,
25825 MixedLinePolicy::CommentOnly
25826 );
25827 }
25828
25829 #[test]
25830 fn mixed_policy_separate_mixed_category() {
25831 let mut form = blank_form();
25832 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
25833 assert_eq!(
25834 apply(&form).analysis.mixed_line_policy,
25835 MixedLinePolicy::SeparateMixedCategory
25836 );
25837 }
25838
25839 #[test]
25842 fn binary_behavior_skip_when_absent() {
25843 assert_eq!(
25844 apply(&blank_form()).analysis.binary_file_behavior,
25845 BinaryFileBehavior::Skip
25846 );
25847 }
25848
25849 #[test]
25850 fn binary_behavior_skip() {
25851 let mut form = blank_form();
25852 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
25853 assert_eq!(
25854 apply(&form).analysis.binary_file_behavior,
25855 BinaryFileBehavior::Skip
25856 );
25857 }
25858
25859 #[test]
25860 fn binary_behavior_fail() {
25861 let mut form = blank_form();
25862 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
25863 assert_eq!(
25864 apply(&form).analysis.binary_file_behavior,
25865 BinaryFileBehavior::Fail
25866 );
25867 }
25868
25869 #[test]
25872 fn continuation_policy_each_physical_when_absent() {
25873 assert_eq!(
25874 apply(&blank_form()).analysis.continuation_line_policy,
25875 ContinuationLinePolicy::EachPhysicalLine
25876 );
25877 }
25878
25879 #[test]
25880 fn continuation_policy_collapse_to_logical() {
25881 let mut form = blank_form();
25882 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
25883 assert_eq!(
25884 apply(&form).analysis.continuation_line_policy,
25885 ContinuationLinePolicy::CollapseToLogical
25886 );
25887 }
25888
25889 #[test]
25892 fn blank_in_block_comment_count_as_comment_when_absent() {
25893 assert_eq!(
25894 apply(&blank_form()).analysis.blank_in_block_comment_policy,
25895 BlankInBlockCommentPolicy::CountAsComment
25896 );
25897 }
25898
25899 #[test]
25900 fn blank_in_block_comment_count_as_blank() {
25901 let mut form = blank_form();
25902 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
25903 assert_eq!(
25904 apply(&form).analysis.blank_in_block_comment_policy,
25905 BlankInBlockCommentPolicy::CountAsBlank
25906 );
25907 }
25908
25909 #[test]
25912 fn style_threshold_80() {
25913 let mut form = blank_form();
25914 form.style_col_threshold = Some("80".to_string());
25915 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
25916 }
25917
25918 #[test]
25919 fn style_threshold_100() {
25920 let mut form = blank_form();
25921 form.style_col_threshold = Some("100".to_string());
25922 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
25923 }
25924
25925 #[test]
25926 fn style_threshold_120() {
25927 let mut form = blank_form();
25928 form.style_col_threshold = Some("120".to_string());
25929 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
25930 }
25931
25932 #[test]
25933 fn style_threshold_invalid_value_leaves_default() {
25934 let mut cfg = sloc_config::AppConfig::default();
25936 let mut form = blank_form();
25937 form.style_col_threshold = Some("42".to_string());
25938 apply_form_to_config(&mut cfg, &form);
25939 assert_eq!(
25940 cfg.analysis.style_col_threshold, 80,
25941 "invalid threshold must not change config"
25942 );
25943 }
25944
25945 #[test]
25946 fn style_threshold_non_numeric_leaves_default() {
25947 let mut cfg = sloc_config::AppConfig::default();
25948 let mut form = blank_form();
25949 form.style_col_threshold = Some("large".to_string());
25950 apply_form_to_config(&mut cfg, &form);
25951 assert_eq!(cfg.analysis.style_col_threshold, 80);
25952 }
25953
25954 #[test]
25955 fn style_threshold_zero_leaves_default() {
25956 let mut cfg = sloc_config::AppConfig::default();
25957 let mut form = blank_form();
25958 form.style_col_threshold = Some("0".to_string());
25959 apply_form_to_config(&mut cfg, &form);
25960 assert_eq!(cfg.analysis.style_col_threshold, 80);
25961 }
25962
25963 #[test]
25964 fn style_threshold_absent_leaves_default() {
25965 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
25966 }
25967
25968 #[test]
25971 fn coverage_file_none_when_absent() {
25972 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
25973 }
25974
25975 #[test]
25976 fn coverage_file_none_when_whitespace_only() {
25977 let mut form = blank_form();
25978 form.coverage_file = Some(" ".to_string());
25979 assert!(
25980 apply(&form).analysis.coverage_file.is_none(),
25981 "whitespace-only coverage_file must be treated as None"
25982 );
25983 }
25984
25985 #[test]
25986 fn coverage_file_set_when_non_empty() {
25987 let mut form = blank_form();
25988 form.coverage_file = Some("coverage/lcov.info".to_string());
25989 assert_eq!(
25990 apply(&form).analysis.coverage_file,
25991 Some(std::path::PathBuf::from("coverage/lcov.info"))
25992 );
25993 }
25994
25995 #[test]
25996 fn coverage_file_trims_whitespace() {
25997 let mut form = blank_form();
25998 form.coverage_file = Some(" coverage/lcov.info ".to_string());
25999 assert_eq!(
26000 apply(&form).analysis.coverage_file,
26001 Some(std::path::PathBuf::from("coverage/lcov.info"))
26002 );
26003 }
26004
26005 #[test]
26008 fn report_title_unchanged_when_absent() {
26009 let original = sloc_config::AppConfig::default().reporting.report_title;
26010 assert_eq!(apply(&blank_form()).reporting.report_title, original);
26011 }
26012
26013 #[test]
26014 fn report_title_unchanged_when_whitespace_only() {
26015 let original = sloc_config::AppConfig::default().reporting.report_title;
26016 let mut form = blank_form();
26017 form.report_title = Some(" ".to_string());
26018 assert_eq!(
26019 apply(&form).reporting.report_title,
26020 original,
26021 "whitespace-only title must not overwrite the default"
26022 );
26023 }
26024
26025 #[test]
26026 fn report_title_updated_and_trimmed() {
26027 let mut form = blank_form();
26028 form.report_title = Some(" My Project ".to_string());
26029 assert_eq!(apply(&form).reporting.report_title, "My Project");
26030 }
26031
26032 #[test]
26035 fn header_footer_none_when_absent() {
26036 assert!(apply(&blank_form())
26037 .reporting
26038 .report_header_footer
26039 .is_none());
26040 }
26041
26042 #[test]
26043 fn header_footer_none_when_whitespace_only() {
26044 let mut form = blank_form();
26045 form.report_header_footer = Some(" ".to_string());
26046 assert!(apply(&form).reporting.report_header_footer.is_none());
26047 }
26048
26049 #[test]
26050 fn header_footer_set_and_trimmed() {
26051 let mut form = blank_form();
26052 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
26053 assert_eq!(
26054 apply(&form).reporting.report_header_footer,
26055 Some("Confidential — Internal Use".to_string())
26056 );
26057 }
26058
26059 #[test]
26062 fn include_globs_empty_when_absent() {
26063 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
26064 }
26065
26066 #[test]
26067 fn include_globs_newline_separated() {
26068 let mut form = blank_form();
26069 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
26070 assert_eq!(
26071 apply(&form).discovery.include_globs,
26072 vec!["src/**/*.rs", "tests/**/*.rs"]
26073 );
26074 }
26075
26076 #[test]
26077 fn exclude_globs_comma_separated() {
26078 let mut form = blank_form();
26079 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
26080 assert_eq!(
26081 apply(&form).discovery.exclude_globs,
26082 vec!["vendor/**", "node_modules/**"]
26083 );
26084 }
26085
26086 #[test]
26087 fn globs_mixed_separators() {
26088 let mut form = blank_form();
26089 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
26090 assert_eq!(
26091 apply(&form).discovery.exclude_globs,
26092 vec!["a/**", "b/**", "c/**"]
26093 );
26094 }
26095
26096 #[test]
26099 fn split_patterns_none_is_empty() {
26100 assert!(split_patterns(None).is_empty());
26101 }
26102
26103 #[test]
26104 fn split_patterns_empty_string_is_empty() {
26105 assert!(split_patterns(Some("")).is_empty());
26106 }
26107
26108 #[test]
26109 fn split_patterns_whitespace_only_is_empty() {
26110 assert!(split_patterns(Some(" \n \n ")).is_empty());
26111 }
26112
26113 #[test]
26114 fn split_patterns_newlines() {
26115 assert_eq!(
26116 split_patterns(Some("a/**\nb/**\nc/**")),
26117 vec!["a/**", "b/**", "c/**"]
26118 );
26119 }
26120
26121 #[test]
26122 fn split_patterns_commas() {
26123 assert_eq!(
26124 split_patterns(Some("a/**,b/**,c/**")),
26125 vec!["a/**", "b/**", "c/**"]
26126 );
26127 }
26128
26129 #[test]
26130 fn split_patterns_mixed() {
26131 assert_eq!(
26132 split_patterns(Some("a/**\nb/**,c/**")),
26133 vec!["a/**", "b/**", "c/**"]
26134 );
26135 }
26136
26137 #[test]
26138 fn split_patterns_trims_whitespace() {
26139 assert_eq!(
26140 split_patterns(Some(" a/** \n b/** ")),
26141 vec!["a/**", "b/**"]
26142 );
26143 }
26144
26145 #[test]
26146 fn split_patterns_filters_empty_entries() {
26147 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
26148 }
26149
26150 #[test]
26151 fn split_patterns_single_entry() {
26152 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
26153 }
26154}
26155
26156#[cfg(test)]
26157mod utility_tests {
26158 use super::*;
26159 use std::net::IpAddr;
26160 use std::time::Duration;
26161
26162 #[test]
26165 fn sanitize_simple_name() {
26166 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
26167 }
26168
26169 #[test]
26170 fn sanitize_uppercased_lowercased() {
26171 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
26172 }
26173
26174 #[test]
26175 fn sanitize_path_extracts_filename() {
26176 assert_eq!(
26177 sanitize_project_label("/home/user/my-project"),
26178 "my-project"
26179 );
26180 }
26181
26182 #[test]
26183 fn sanitize_path_uses_last_component() {
26184 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
26185 }
26186
26187 #[test]
26188 fn sanitize_spaces_become_hyphens() {
26189 assert_eq!(sanitize_project_label("my project"), "my-project");
26190 }
26191
26192 #[test]
26193 fn sanitize_non_ascii_become_hyphens() {
26194 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
26195 }
26196
26197 #[test]
26198 fn sanitize_all_special_chars_gives_project() {
26199 assert_eq!(sanitize_project_label("!@#$%^"), "project");
26200 }
26201
26202 #[test]
26203 fn sanitize_empty_string_gives_project() {
26204 assert_eq!(sanitize_project_label(""), "project");
26205 }
26206
26207 #[test]
26208 fn sanitize_leading_trailing_hyphens_stripped() {
26209 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
26210 }
26211
26212 #[test]
26213 fn sanitize_alphanumeric_preserved() {
26214 assert_eq!(sanitize_project_label("repo123"), "repo123");
26215 }
26216
26217 #[test]
26218 fn sanitize_dots_become_hyphens() {
26219 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
26220 }
26221
26222 #[test]
26223 fn sanitize_mixed_slashes_uses_filename() {
26224 assert_eq!(sanitize_project_label("project-name"), "project-name");
26226 }
26227
26228 #[test]
26231 fn rate_limiter_allows_first_request() {
26232 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
26233 let ip: IpAddr = "127.0.0.1".parse().unwrap();
26234 assert!(rl.is_allowed(ip));
26235 }
26236
26237 #[test]
26238 fn rate_limiter_blocks_after_limit_reached() {
26239 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
26240 let ip: IpAddr = "10.0.0.1".parse().unwrap();
26241 assert!(rl.is_allowed(ip));
26242 assert!(rl.is_allowed(ip));
26243 assert!(rl.is_allowed(ip));
26244 assert!(!rl.is_allowed(ip), "4th request must be blocked");
26245 }
26246
26247 #[test]
26248 fn rate_limiter_allows_requests_up_to_limit() {
26249 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
26250 let ip: IpAddr = "10.0.0.2".parse().unwrap();
26251 for _ in 0..5 {
26252 assert!(rl.is_allowed(ip));
26253 }
26254 assert!(!rl.is_allowed(ip), "6th request must be blocked");
26255 }
26256
26257 #[test]
26258 fn rate_limiter_different_ips_are_independent() {
26259 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
26260 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
26261 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
26262 assert!(rl.is_allowed(ip1));
26263 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
26264 assert!(rl.is_allowed(ip2), "ip2 must be independent");
26265 }
26266
26267 #[test]
26268 fn rate_limiter_auth_failure_not_locked_below_threshold() {
26269 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
26270 let ip: IpAddr = "10.0.0.3".parse().unwrap();
26271 rl.record_auth_failure(ip);
26272 rl.record_auth_failure(ip);
26273 assert!(
26274 !rl.is_auth_locked_out(ip),
26275 "not locked at 2 failures when threshold is 3"
26276 );
26277 }
26278
26279 #[test]
26280 fn rate_limiter_auth_failure_locked_at_threshold() {
26281 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
26282 let ip: IpAddr = "10.0.0.4".parse().unwrap();
26283 rl.record_auth_failure(ip);
26284 rl.record_auth_failure(ip);
26285 rl.record_auth_failure(ip);
26286 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
26287 }
26288
26289 #[test]
26290 fn rate_limiter_auth_failure_different_ips_independent() {
26291 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
26292 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
26293 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
26294 rl.record_auth_failure(ip1);
26295 rl.record_auth_failure(ip1);
26296 assert!(rl.is_auth_locked_out(ip1));
26297 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
26298 }
26299
26300 #[test]
26301 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
26302 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
26303 let ip: IpAddr = "127.0.0.2".parse().unwrap();
26304 for _ in 0..100 {
26305 assert!(rl.is_allowed(ip));
26306 }
26307 }
26308
26309 #[test]
26312 fn strip_unc_plain_path_unchanged() {
26313 let p = PathBuf::from("C:\\Users\\user\\project");
26314 let result = strip_unc_prefix(p.clone());
26315 assert_eq!(result, p);
26316 }
26317
26318 #[test]
26319 fn strip_unc_with_drive_prefix_stripped() {
26320 let p = PathBuf::from(r"\\?\C:\Users\user\project");
26321 let result = strip_unc_prefix(p);
26322 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
26323 }
26324
26325 #[test]
26326 fn strip_unc_with_network_prefix_stripped() {
26327 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
26328 let result = strip_unc_prefix(p);
26329 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
26330 }
26331
26332 #[test]
26333 fn strip_unc_linux_path_unchanged() {
26334 let p = PathBuf::from("/home/user/project");
26335 let result = strip_unc_prefix(p.clone());
26336 assert_eq!(result, p);
26337 }
26338
26339 #[test]
26342 fn remote_to_commit_url_github_https() {
26343 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
26344 assert_eq!(
26345 url,
26346 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
26347 );
26348 }
26349
26350 #[test]
26351 fn remote_to_commit_url_github_ssh() {
26352 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
26353 assert_eq!(
26354 url,
26355 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
26356 );
26357 }
26358
26359 #[test]
26360 fn remote_to_commit_url_gitlab_uses_dash_commit() {
26361 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
26362 assert_eq!(
26363 url,
26364 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
26365 );
26366 }
26367
26368 #[test]
26369 fn remote_to_commit_url_bitbucket_uses_commits() {
26370 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
26371 assert_eq!(
26372 url,
26373 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
26374 );
26375 }
26376
26377 #[test]
26378 fn remote_to_commit_url_unknown_scheme_returns_none() {
26379 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
26380 assert!(url.is_none());
26381 }
26382
26383 #[test]
26384 fn remote_to_commit_url_ssh_gitlab() {
26385 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
26386 assert!(url.is_some());
26387 let u = url.unwrap();
26388 assert!(
26389 u.contains("/-/commit/sha123"),
26390 "gitlab ssh must use /-/commit/"
26391 );
26392 }
26393
26394 #[test]
26397 fn git_clone_dest_github_url_produces_safe_name() {
26398 let dir = PathBuf::from("/tmp/clones");
26399 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
26400 let name = dest.file_name().unwrap().to_string_lossy();
26401 assert!(!name.is_empty());
26402 assert!(
26403 name.chars()
26404 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
26405 "clone dest must only contain safe chars, got: {name}"
26406 );
26407 }
26408
26409 #[test]
26410 fn git_clone_dest_is_inside_clones_dir() {
26411 let dir = PathBuf::from("/tmp/clones");
26412 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
26413 assert!(
26414 dest.starts_with(&dir),
26415 "clone dest must be inside clones_dir"
26416 );
26417 }
26418
26419 #[test]
26420 fn git_clone_dest_truncates_to_80_chars_max() {
26421 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
26422 let dir = PathBuf::from("/tmp/clones");
26423 let dest = git_clone_dest(&long_url, &dir);
26424 let name = dest.file_name().unwrap().to_string_lossy();
26425 assert!(
26426 name.len() <= 80,
26427 "clone dest name must be at most 80 chars, got {} chars: {name}",
26428 name.len()
26429 );
26430 }
26431
26432 #[test]
26433 fn git_clone_dest_special_chars_replaced_with_underscore() {
26434 let dir = PathBuf::from("/tmp/clones");
26435 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
26436 let name = dest.file_name().unwrap().to_string_lossy();
26437 assert!(
26438 !name.contains('@') && !name.contains(':') && !name.contains('/'),
26439 "special chars must be replaced in clone dest, got: {name}"
26440 );
26441 }
26442
26443 #[test]
26444 fn git_clone_dest_different_urls_differ() {
26445 let dir = PathBuf::from("/tmp/clones");
26446 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
26447 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
26448 assert_ne!(
26449 a, b,
26450 "different repos must produce different clone dest names"
26451 );
26452 }
26453
26454 #[test]
26455 fn git_clone_dest_same_url_same_result() {
26456 let dir = PathBuf::from("/tmp/clones");
26457 let url = "https://github.com/owner/repo.git";
26458 assert_eq!(
26459 git_clone_dest(url, &dir),
26460 git_clone_dest(url, &dir),
26461 "same URL must always give same clone dest"
26462 );
26463 }
26464
26465 #[test]
26468 fn fmt_delta_positive_has_plus_prefix() {
26469 assert_eq!(fmt_delta(5), "+5");
26470 }
26471
26472 #[test]
26473 fn fmt_delta_negative_no_plus_prefix() {
26474 assert_eq!(fmt_delta(-3), "-3");
26475 }
26476
26477 #[test]
26478 fn fmt_delta_zero() {
26479 assert_eq!(fmt_delta(0), "0");
26480 }
26481
26482 #[test]
26485 fn delta_class_positive_is_pos() {
26486 assert_eq!(delta_class(1), "pos");
26487 }
26488
26489 #[test]
26490 fn delta_class_negative_is_neg() {
26491 assert_eq!(delta_class(-1), "neg");
26492 }
26493
26494 #[test]
26495 fn delta_class_zero_is_zero_class() {
26496 assert_eq!(delta_class(0), "zero");
26497 }
26498
26499 #[test]
26502 fn fmt_pct_zero_baseline_returns_em_dash() {
26503 assert_eq!(fmt_pct(100, 0), "\u{2014}");
26504 }
26505
26506 #[test]
26507 fn fmt_pct_positive_delta_has_plus_sign() {
26508 let result = fmt_pct(10, 100);
26509 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
26510 }
26511
26512 #[test]
26513 fn fmt_pct_negative_delta_no_plus_sign() {
26514 let result = fmt_pct(-10, 100);
26515 assert!(!result.starts_with('+'), "unexpected + in: {result}");
26516 assert!(result.contains('%'));
26517 }
26518
26519 #[test]
26520 fn fmt_pct_near_zero_returns_pm_zero() {
26521 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
26522 }
26523
26524 #[test]
26527 fn summary_delta_no_prev_returns_dash_na() {
26528 let (display, class) = summary_delta(10, None);
26529 assert_eq!(display, "\u{2014}");
26530 assert_eq!(class, "na");
26531 }
26532
26533 #[test]
26534 fn summary_delta_increase_is_positive() {
26535 let (display, class) = summary_delta(15, Some(10));
26536 assert_eq!(display, "+5");
26537 assert_eq!(class, "pos");
26538 }
26539
26540 #[test]
26541 fn summary_delta_decrease_is_negative() {
26542 let (display, class) = summary_delta(5, Some(10));
26543 assert_eq!(display, "-5");
26544 assert_eq!(class, "neg");
26545 }
26546
26547 #[test]
26550 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
26551 use chrono::Datelike;
26552 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
26553 assert_eq!(d.year(), 2024);
26554 assert_eq!(d.month(), 1);
26555 assert_eq!(d.weekday(), chrono::Weekday::Mon);
26556 assert!(d.day() <= 7);
26557 }
26558
26559 #[test]
26560 fn nth_weekday_second_sunday_march_2024_is_10th() {
26561 use chrono::Datelike;
26562 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
26563 assert_eq!(d.weekday(), chrono::Weekday::Sun);
26564 assert_eq!(d.month(), 3);
26565 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
26566 }
26567
26568 #[test]
26571 fn is_pacific_dst_july_is_true() {
26572 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
26573 assert!(is_pacific_dst(dt), "July must be PDT");
26574 }
26575
26576 #[test]
26577 fn is_pacific_dst_january_is_false() {
26578 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
26579 assert!(!is_pacific_dst(dt), "January must be PST");
26580 }
26581
26582 #[test]
26583 fn fmt_la_time_summer_shows_pdt() {
26584 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
26585 let result = fmt_la_time(dt);
26586 assert!(
26587 result.ends_with("PDT"),
26588 "summer must use PDT, got: {result}"
26589 );
26590 }
26591
26592 #[test]
26593 fn fmt_la_time_winter_shows_pst() {
26594 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
26595 let result = fmt_la_time(dt);
26596 assert!(
26597 result.ends_with("PST"),
26598 "winter must use PST, got: {result}"
26599 );
26600 }
26601
26602 #[test]
26603 fn fmt_la_time_meta_summer_shows_pdt() {
26604 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
26605 let result = fmt_la_time_meta(dt);
26606 assert!(
26607 result.ends_with("PDT"),
26608 "meta summer must use PDT, got: {result}"
26609 );
26610 }
26611
26612 #[test]
26613 fn fmt_la_time_meta_winter_shows_pst() {
26614 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
26615 let result = fmt_la_time_meta(dt);
26616 assert!(
26617 result.ends_with("PST"),
26618 "meta winter must use PST, got: {result}"
26619 );
26620 }
26621
26622 #[test]
26625 fn fmt_git_date_valid_iso_returns_some() {
26626 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
26627 }
26628
26629 #[test]
26630 fn fmt_git_date_invalid_returns_none() {
26631 assert!(fmt_git_date("not-a-date").is_none());
26632 }
26633
26634 #[test]
26637 fn format_number_zero() {
26638 assert_eq!(format_number(0), "0");
26639 }
26640
26641 #[test]
26642 fn format_number_three_digits_no_comma() {
26643 assert_eq!(format_number(999), "999");
26644 }
26645
26646 #[test]
26647 fn format_number_four_digits_has_comma() {
26648 assert_eq!(format_number(1000), "1,000");
26649 }
26650
26651 #[test]
26652 fn format_number_seven_digits_two_commas() {
26653 assert_eq!(format_number(1_234_567), "1,234,567");
26654 }
26655
26656 #[test]
26657 fn format_number_one_million() {
26658 assert_eq!(format_number(1_000_000), "1,000,000");
26659 }
26660
26661 #[test]
26664 fn badge_text_px_empty_is_zero() {
26665 assert_eq!(badge_text_px(""), 0);
26666 }
26667
26668 #[test]
26669 fn badge_text_px_narrow_chars_smaller_than_normal() {
26670 assert!(
26671 badge_text_px("if") < badge_text_px("ab"),
26672 "'if' must be narrower than 'ab'"
26673 );
26674 }
26675
26676 #[test]
26677 fn badge_text_px_m_is_wider_than_a() {
26678 assert!(
26679 badge_text_px("m") > badge_text_px("a"),
26680 "'m' must be wider than 'a'"
26681 );
26682 }
26683
26684 #[test]
26685 fn render_badge_svg_contains_label_and_value() {
26686 let svg = render_badge_svg("coverage", "95%", "#4c1");
26687 assert!(svg.contains("coverage") && svg.contains("95%"));
26688 }
26689
26690 #[test]
26691 fn render_badge_svg_contains_color() {
26692 let svg = render_badge_svg("sloc", "12K", "#e05d44");
26693 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
26694 }
26695
26696 #[test]
26697 fn render_badge_svg_escapes_ampersand_in_label() {
26698 let svg = render_badge_svg("test&label", "ok", "#4c1");
26699 assert!(svg.contains("&") && !svg.contains("test&label"));
26700 }
26701
26702 #[test]
26705 fn build_pdf_filename_slugifies_title() {
26706 let name = build_pdf_filename("My Project Report", "abc-def-1234");
26707 assert!(
26708 name.starts_with("my_project_report_")
26709 && std::path::Path::new(&name)
26710 .extension()
26711 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
26712 );
26713 }
26714
26715 #[test]
26716 fn build_pdf_filename_uses_last_run_id_segment() {
26717 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
26718 assert!(name.contains("ABCD"), "must use last segment of run_id");
26719 }
26720
26721 #[test]
26722 fn build_pdf_filename_empty_title_uses_report_prefix() {
26723 let name = build_pdf_filename("", "abc-def-9999");
26724 assert!(
26725 name.starts_with("report_")
26726 && std::path::Path::new(&name)
26727 .extension()
26728 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
26729 );
26730 }
26731
26732 #[test]
26735 fn swap_chart_js_replaces_inline_block() {
26736 let html = "<html><head><script>// inline source</script></head><body></body></html>";
26737 let result = swap_inline_chart_js_for_static(html.to_string());
26738 assert!(result.contains(r#"src="/static/chart-report.js""#));
26739 assert!(!result.contains("inline source"));
26740 }
26741
26742 #[test]
26743 fn swap_chart_js_no_head_returns_unchanged() {
26744 let html = "<body>no head here</body>";
26745 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
26746 }
26747
26748 #[test]
26749 fn swap_chart_js_no_script_in_head_unchanged() {
26750 let html = "<html><head><style>.x{}</style></head><body></body></html>";
26751 let result = swap_inline_chart_js_for_static(html.to_string());
26752 assert!(!result.contains("chart-report.js"));
26753 }
26754
26755 #[test]
26758 fn patch_html_nonce_replaces_old_nonce() {
26759 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
26760 let result = patch_html_nonce(html, "new-nonce-456");
26761 assert!(result.contains(r#"nonce="new-nonce-456""#));
26762 assert!(!result.contains("old-nonce-123"));
26763 }
26764
26765 #[test]
26766 fn patch_html_nonce_injects_into_bare_style() {
26767 let html = "<style>body{color:red;}</style>";
26768 let result = patch_html_nonce(html, "fresh-nonce");
26769 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
26770 }
26771
26772 #[test]
26773 fn patch_html_nonce_injects_into_bare_script() {
26774 let html = "<script>console.log(1);</script>";
26775 let result = patch_html_nonce(html, "abc");
26776 assert!(result.contains(r#"<script nonce="abc">"#));
26777 }
26778
26779 #[test]
26782 fn is_html_report_file_result_html_matches() {
26783 let dir = tempfile::tempdir().unwrap();
26784 let path = dir.path().join("result_20240101.html");
26785 std::fs::write(&path, b"<html></html>").unwrap();
26786 assert!(is_html_report_file(&path));
26787 }
26788
26789 #[test]
26790 fn is_html_report_file_report_html_matches() {
26791 let dir = tempfile::tempdir().unwrap();
26792 let path = dir.path().join("report_abc.html");
26793 std::fs::write(&path, b"<html></html>").unwrap();
26794 assert!(is_html_report_file(&path));
26795 }
26796
26797 #[test]
26798 fn is_html_report_file_index_html_does_not_match() {
26799 let dir = tempfile::tempdir().unwrap();
26800 let path = dir.path().join("index.html");
26801 std::fs::write(&path, b"<html></html>").unwrap();
26802 assert!(!is_html_report_file(&path));
26803 }
26804
26805 #[test]
26806 fn is_html_report_file_nonexistent_returns_false() {
26807 assert!(!is_html_report_file(Path::new(
26808 "/nonexistent/result_xyz.html"
26809 )));
26810 }
26811
26812 #[test]
26813 fn find_html_report_in_dir_finds_result_html() {
26814 let dir = tempfile::tempdir().unwrap();
26815 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
26816 assert!(find_html_report_in_dir(dir.path()).is_some());
26817 }
26818
26819 #[test]
26820 fn find_html_report_in_dir_empty_returns_none() {
26821 let dir = tempfile::tempdir().unwrap();
26822 assert!(find_html_report_in_dir(dir.path()).is_none());
26823 }
26824
26825 #[test]
26826 fn find_html_report_in_tree_finds_in_subdir() {
26827 let dir = tempfile::tempdir().unwrap();
26828 let subdir = dir.path().join("run-001");
26829 std::fs::create_dir_all(&subdir).unwrap();
26830 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
26831 assert!(find_html_report_in_tree(dir.path()).is_some());
26832 }
26833
26834 #[test]
26837 fn derive_project_label_with_git_repo_and_ref() {
26838 let label = derive_project_label(
26839 Some("https://github.com/owner/my-repo.git"),
26840 Some("main"),
26841 "/fallback/path",
26842 );
26843 assert!(!label.is_empty(), "label must not be empty");
26844 assert!(
26845 label.contains("my") || label.contains("repo"),
26846 "got: {label}"
26847 );
26848 }
26849
26850 #[test]
26851 fn derive_project_label_fallback_to_path() {
26852 let label = derive_project_label(None, None, "/path/to/myproject");
26853 assert_eq!(label, "myproject");
26854 }
26855
26856 #[test]
26857 fn derive_project_label_empty_git_fields_use_path() {
26858 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
26859 assert_eq!(label, "cool-app");
26860 }
26861
26862 #[test]
26865 fn derive_file_stem_with_commit_appends_sha() {
26866 assert_eq!(
26867 derive_file_stem("myproject", Some("a1b2c3")),
26868 "myproject_a1b2c3"
26869 );
26870 }
26871
26872 #[test]
26873 fn derive_file_stem_without_commit_returns_label() {
26874 assert_eq!(derive_file_stem("myproject", None), "myproject");
26875 }
26876
26877 #[test]
26878 fn derive_file_stem_empty_commit_returns_label() {
26879 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
26880 }
26881
26882 #[test]
26885 fn split_patterns_none_is_empty() {
26886 assert!(split_patterns(None).is_empty());
26887 }
26888
26889 #[test]
26890 fn split_patterns_empty_string_is_empty() {
26891 assert!(split_patterns(Some("")).is_empty());
26892 }
26893
26894 #[test]
26895 fn split_patterns_comma_separated() {
26896 assert_eq!(
26897 split_patterns(Some("foo,bar,baz")),
26898 vec!["foo", "bar", "baz"]
26899 );
26900 }
26901
26902 #[test]
26903 fn split_patterns_newline_separated() {
26904 assert_eq!(
26905 split_patterns(Some("foo\nbar\nbaz")),
26906 vec!["foo", "bar", "baz"]
26907 );
26908 }
26909
26910 #[test]
26911 fn split_patterns_trims_whitespace() {
26912 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
26913 }
26914
26915 #[test]
26918 fn make_git_label_empty_repo_empty_result() {
26919 assert_eq!(make_git_label("", "main"), "");
26920 }
26921
26922 #[test]
26923 fn make_git_label_empty_ref_empty_result() {
26924 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
26925 }
26926
26927 #[test]
26928 fn make_git_label_basic_format() {
26929 assert_eq!(
26930 make_git_label("https://github.com/owner/my-repo.git", "main"),
26931 "my-repo_at_main_sloc"
26932 );
26933 }
26934
26935 #[test]
26936 fn make_git_label_slash_in_ref_replaced() {
26937 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
26938 assert!(
26939 !label.contains('/'),
26940 "slash in ref must be replaced: {label}"
26941 );
26942 }
26943
26944 #[test]
26947 fn format_dir_size_bytes() {
26948 assert_eq!(format_dir_size(500), "500 B");
26949 }
26950
26951 #[test]
26952 fn format_dir_size_kilobytes() {
26953 assert_eq!(format_dir_size(2048), "2 KB");
26954 }
26955
26956 #[test]
26957 fn format_dir_size_megabytes() {
26958 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
26959 }
26960
26961 #[test]
26962 fn format_dir_size_gigabytes() {
26963 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
26964 }
26965
26966 #[test]
26967 fn format_dir_size_zero() {
26968 assert_eq!(format_dir_size(0), "0 B");
26969 }
26970
26971 #[test]
26974 fn civil_from_days_epoch() {
26975 assert_eq!(civil_from_days(0), (1970, 1, 1));
26976 }
26977
26978 #[test]
26979 fn civil_from_days_one_year_later() {
26980 assert_eq!(civil_from_days(365), (1971, 1, 1));
26981 }
26982
26983 #[test]
26984 fn civil_from_days_31_days_is_feb_1_1970() {
26985 assert_eq!(civil_from_days(31), (1970, 2, 1));
26986 }
26987
26988 #[test]
26991 fn format_system_time_unix_epoch_formats_correctly() {
26992 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
26993 }
26994
26995 #[test]
26996 fn format_system_time_31_days_after_epoch() {
26997 let t = UNIX_EPOCH + Duration::from_hours(744);
26998 assert_eq!(format_system_time(t), "1970-02-01 00:00");
26999 }
27000
27001 #[test]
27002 fn format_system_time_before_epoch_returns_dash() {
27003 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
27004 assert_eq!(format_system_time(before), "-");
27005 }
27006 }
27007
27008 #[test]
27011 fn detect_language_name_dot_c() {
27012 assert_eq!(detect_language_name("main.c"), Some("C"));
27013 }
27014
27015 #[test]
27016 fn detect_language_name_dot_h() {
27017 assert_eq!(detect_language_name("defs.h"), Some("C"));
27018 }
27019
27020 #[test]
27021 fn detect_language_name_dot_cpp() {
27022 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
27023 }
27024
27025 #[test]
27026 fn detect_language_name_dot_py() {
27027 assert_eq!(detect_language_name("script.py"), Some("Python"));
27028 }
27029
27030 #[test]
27031 fn detect_language_name_dot_ps1() {
27032 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
27033 }
27034
27035 #[test]
27036 fn detect_language_name_dot_cs() {
27037 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
27038 }
27039
27040 #[test]
27041 fn detect_language_name_dot_sh() {
27042 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
27043 }
27044
27045 #[test]
27046 fn detect_language_name_unknown_txt() {
27047 assert_eq!(detect_language_name("notes.txt"), None);
27048 }
27049
27050 #[test]
27053 fn language_icon_file_c() {
27054 assert_eq!(language_icon_file("C"), Some("c.png"));
27055 }
27056
27057 #[test]
27058 fn language_icon_file_python() {
27059 assert_eq!(language_icon_file("Python"), Some("python.png"));
27060 }
27061
27062 #[test]
27063 fn language_icon_file_dockerfile() {
27064 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
27065 }
27066
27067 #[test]
27068 fn language_icon_file_rust_is_none() {
27069 assert!(language_icon_file("Rust").is_none());
27070 }
27071
27072 #[test]
27073 fn language_icon_file_unknown_is_none() {
27074 assert!(language_icon_file("Fortran").is_none());
27075 }
27076
27077 #[test]
27080 fn language_inline_svg_rust_is_svg() {
27081 let svg = language_inline_svg("Rust").unwrap();
27082 assert!(svg.starts_with("<svg"));
27083 }
27084
27085 #[test]
27086 fn language_inline_svg_typescript_is_some() {
27087 assert!(language_inline_svg("TypeScript").is_some());
27088 }
27089
27090 #[test]
27091 fn language_inline_svg_unknown_is_none() {
27092 assert!(language_inline_svg("Fortran").is_none());
27093 }
27094
27095 #[test]
27098 fn classify_preview_file_c_supported() {
27099 assert!(matches!(
27100 classify_preview_file("main.c"),
27101 PreviewKind::Supported
27102 ));
27103 }
27104
27105 #[test]
27106 fn classify_preview_file_python_supported() {
27107 assert!(matches!(
27108 classify_preview_file("script.py"),
27109 PreviewKind::Supported
27110 ));
27111 }
27112
27113 #[test]
27114 fn classify_preview_file_png_skipped() {
27115 assert!(matches!(
27116 classify_preview_file("image.png"),
27117 PreviewKind::Skipped
27118 ));
27119 }
27120
27121 #[test]
27122 fn classify_preview_file_zip_skipped() {
27123 assert!(matches!(
27124 classify_preview_file("archive.zip"),
27125 PreviewKind::Skipped
27126 ));
27127 }
27128
27129 #[test]
27130 fn classify_preview_file_min_js_skipped() {
27131 assert!(matches!(
27132 classify_preview_file("bundle.min.js"),
27133 PreviewKind::Skipped
27134 ));
27135 }
27136
27137 #[test]
27138 fn classify_preview_file_rs_unsupported() {
27139 assert!(matches!(
27140 classify_preview_file("main.rs"),
27141 PreviewKind::Unsupported
27142 ));
27143 }
27144
27145 #[test]
27148 fn preview_relative_path_strips_root() {
27149 let root = PathBuf::from("/project");
27150 let path = PathBuf::from("/project/src/main.c");
27151 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
27152 }
27153
27154 #[test]
27155 fn preview_relative_path_unrooted_includes_filename() {
27156 let root = PathBuf::from("/other");
27157 let path = PathBuf::from("/project/src/main.c");
27158 let result = preview_relative_path(&root, &path);
27159 assert!(result.contains("main.c"));
27160 }
27161
27162 #[test]
27163 fn preview_relative_path_uses_forward_slashes() {
27164 let root = PathBuf::from("/project");
27165 let path = PathBuf::from("/project/a/b/c.py");
27166 assert!(!preview_relative_path(&root, &path).contains('\\'));
27167 }
27168
27169 #[test]
27172 fn wildcard_match_exact_equal() {
27173 assert!(wildcard_match("foo", "foo"));
27174 }
27175
27176 #[test]
27177 fn wildcard_match_exact_mismatch() {
27178 assert!(!wildcard_match("foo", "bar"));
27179 }
27180
27181 #[test]
27182 fn wildcard_match_star_suffix() {
27183 assert!(wildcard_match("*.rs", "main.rs"));
27184 }
27185
27186 #[test]
27187 fn wildcard_match_star_middle_requires_suffix() {
27188 assert!(!wildcard_match("a*b", "ac"));
27189 }
27190
27191 #[test]
27192 fn wildcard_match_question_mark_single_char() {
27193 assert!(wildcard_match("f?o", "foo"));
27194 }
27195
27196 #[test]
27197 fn wildcard_match_double_star_nested() {
27198 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
27199 }
27200
27201 #[test]
27202 fn wildcard_match_star_directory_entry() {
27203 assert!(wildcard_match("vendor/*", "vendor/crate"));
27204 }
27205
27206 #[test]
27207 fn wildcard_match_no_cross_prefix() {
27208 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
27209 }
27210
27211 #[test]
27214 fn should_skip_empty_relative_is_false() {
27215 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
27216 }
27217
27218 #[test]
27219 fn should_skip_matching_pattern() {
27220 assert!(should_skip_preview_directory(
27221 "vendor",
27222 &["vendor".to_string()]
27223 ));
27224 }
27225
27226 #[test]
27227 fn should_skip_non_matching() {
27228 assert!(!should_skip_preview_directory(
27229 "src",
27230 &["vendor".to_string()]
27231 ));
27232 }
27233
27234 #[test]
27235 fn should_skip_wildcard_prefix() {
27236 assert!(should_skip_preview_directory(
27237 "target/debug",
27238 &["target*".to_string()]
27239 ));
27240 }
27241
27242 #[test]
27245 fn should_include_empty_relative_always_true() {
27246 assert!(should_include_preview_file("", &[], &[]));
27247 }
27248
27249 #[test]
27250 fn should_include_no_patterns_includes_all() {
27251 assert!(should_include_preview_file("src/main.c", &[], &[]));
27252 }
27253
27254 #[test]
27255 fn should_include_excluded_by_pattern() {
27256 assert!(!should_include_preview_file(
27257 "vendor/lib.c",
27258 &[],
27259 &["vendor/*".to_string()]
27260 ));
27261 }
27262
27263 #[test]
27264 fn should_include_include_pattern_filters() {
27265 assert!(!should_include_preview_file(
27266 "tests/test_foo.c",
27267 &["src/*".to_string()],
27268 &[]
27269 ));
27270 }
27271
27272 #[test]
27275 fn escape_html_ampersand() {
27276 assert_eq!(escape_html("a&b"), "a&b");
27277 }
27278
27279 #[test]
27280 fn escape_html_angle_brackets() {
27281 assert_eq!(escape_html("<br>"), "<br>");
27282 }
27283
27284 #[test]
27285 fn escape_html_double_quote() {
27286 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
27287 }
27288
27289 #[test]
27290 fn escape_html_single_quote() {
27291 assert_eq!(escape_html("it's"), "it's");
27292 }
27293
27294 #[test]
27295 fn escape_html_plain_text_unchanged() {
27296 assert_eq!(escape_html("hello world"), "hello world");
27297 }
27298
27299 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
27302 sloc_core::ScanComparison {
27303 summary: sloc_core::SummaryDelta {
27304 baseline_run_id: "base".to_string(),
27305 current_run_id: "curr".to_string(),
27306 baseline_timestamp: chrono::Utc::now(),
27307 current_timestamp: chrono::Utc::now(),
27308 baseline_files: 4,
27309 current_files: 4,
27310 files_analyzed_delta: 0,
27311 baseline_code: 330,
27312 current_code: 400,
27313 code_lines_delta: 70,
27314 baseline_comments: 0,
27315 current_comments: 0,
27316 comment_lines_delta: 0,
27317 blank_lines_delta: 0,
27318 total_lines_delta: 70,
27319 coverage_lines_hit_delta: None,
27320 coverage_line_pct_delta: None,
27321 baseline_coverage_line_pct: None,
27322 current_coverage_line_pct: None,
27323 },
27324 file_deltas: vec![
27325 sloc_core::FileDelta {
27326 relative_path: "added.rs".to_string(),
27327 language: Some("Rust".to_string()),
27328 status: FileChangeStatus::Added,
27329 baseline_code: 0,
27330 current_code: 100,
27331 code_delta: 100,
27332 baseline_comment: 0,
27333 current_comment: 0,
27334 comment_delta: 0,
27335 baseline_blank: 0,
27336 current_blank: 0,
27337 blank_delta: 0,
27338 total_delta: 100,
27339 },
27340 sloc_core::FileDelta {
27341 relative_path: "removed.rs".to_string(),
27342 language: Some("Rust".to_string()),
27343 status: FileChangeStatus::Removed,
27344 baseline_code: 50,
27345 current_code: 0,
27346 code_delta: -50,
27347 baseline_comment: 0,
27348 current_comment: 0,
27349 comment_delta: 0,
27350 baseline_blank: 0,
27351 current_blank: 0,
27352 blank_delta: 0,
27353 total_delta: -50,
27354 },
27355 sloc_core::FileDelta {
27356 relative_path: "modified.rs".to_string(),
27357 language: Some("Rust".to_string()),
27358 status: FileChangeStatus::Modified,
27359 baseline_code: 80,
27360 current_code: 100,
27361 code_delta: 20,
27362 baseline_comment: 0,
27363 current_comment: 0,
27364 comment_delta: 0,
27365 baseline_blank: 0,
27366 current_blank: 0,
27367 blank_delta: 0,
27368 total_delta: 20,
27369 },
27370 sloc_core::FileDelta {
27371 relative_path: "unchanged.rs".to_string(),
27372 language: Some("Rust".to_string()),
27373 status: FileChangeStatus::Unchanged,
27374 baseline_code: 200,
27375 current_code: 200,
27376 code_delta: 0,
27377 baseline_comment: 0,
27378 current_comment: 0,
27379 comment_delta: 0,
27380 baseline_blank: 0,
27381 current_blank: 0,
27382 blank_delta: 0,
27383 total_delta: 0,
27384 },
27385 ],
27386 files_added: 1,
27387 files_removed: 1,
27388 files_modified: 1,
27389 files_unchanged: 1,
27390 }
27391 }
27392
27393 #[test]
27394 fn sum_added_counts_added_and_positive_modified() {
27395 let cmp = make_mixed_scan_comparison();
27396 assert_eq!(sum_added_code_lines(&cmp), 120);
27397 }
27398
27399 #[test]
27400 fn sum_removed_counts_removed_baseline() {
27401 let cmp = make_mixed_scan_comparison();
27402 assert_eq!(sum_removed_code_lines(&cmp), 50);
27403 }
27404
27405 #[test]
27406 fn sum_unmodified_counts_unchanged_files() {
27407 let cmp = make_mixed_scan_comparison();
27408 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
27409 }
27410
27411 #[test]
27414 fn detect_coverage_tool_rust_project() {
27415 let dir = tempfile::tempdir().unwrap();
27416 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
27417 let (tool, cmd) = detect_coverage_tool(dir.path());
27418 assert_eq!(tool, Some("cargo-llvm-cov"));
27419 assert!(cmd.is_some());
27420 }
27421
27422 #[test]
27423 fn detect_coverage_tool_java_gradle() {
27424 let dir = tempfile::tempdir().unwrap();
27425 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
27426 let (tool, _) = detect_coverage_tool(dir.path());
27427 assert_eq!(tool, Some("jacoco"));
27428 }
27429
27430 #[test]
27431 fn detect_coverage_tool_python_pyproject() {
27432 let dir = tempfile::tempdir().unwrap();
27433 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
27434 let (tool, _) = detect_coverage_tool(dir.path());
27435 assert_eq!(tool, Some("pytest-cov"));
27436 }
27437
27438 #[test]
27439 fn detect_coverage_tool_unknown_project() {
27440 let dir = tempfile::tempdir().unwrap();
27441 let (tool, cmd) = detect_coverage_tool(dir.path());
27442 assert!(tool.is_none() && cmd.is_none());
27443 }
27444
27445 #[test]
27448 fn sanitize_path_str_unc_drive_stripped() {
27449 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
27450 }
27451
27452 #[test]
27453 fn sanitize_path_str_unc_network_stripped() {
27454 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
27455 }
27456
27457 #[test]
27458 fn sanitize_path_str_plain_path_unchanged() {
27459 assert_eq!(
27460 sanitize_path_str("/home/user/project"),
27461 "/home/user/project"
27462 );
27463 }
27464
27465 #[test]
27466 fn display_path_plain_linux_unchanged() {
27467 assert_eq!(
27468 display_path(Path::new("/home/user/project")),
27469 "/home/user/project"
27470 );
27471 }
27472
27473 #[test]
27474 fn display_path_unc_drive_stripped() {
27475 let result = display_path(Path::new(r"\\?\C:\Users\user"));
27476 assert_eq!(result, r"C:\Users\user");
27477 }
27478
27479 #[test]
27480 fn display_path_unc_network_stripped() {
27481 let result = display_path(Path::new(r"\\?\UNC\server\share"));
27482 assert_eq!(result, r"\\server\share");
27483 }
27484}