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: Vec<secrecy::Secret<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: 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: vec![secrecy::Secret::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::Secret<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::Secret<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::Secret::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: 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.clone()));
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(|p| p.to_path_buf())
2658 .unwrap_or_else(|| parent.to_path_buf())
2659}
2660
2661fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2662 let mut hits = collect_result_json_candidates(scan_root);
2663 if hits.is_empty() {
2664 hits = collect_result_json_candidates(parent);
2665 }
2666 hits.sort();
2667 hits
2668}
2669
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.clone()))
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 !expected_run_id.is_empty() {
2780 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == expected_run_id) {
2781 entry.html_path = Some(html_path.clone());
2782 let _ = reg.save(&state.registry_path);
2783 drop(reg);
2784 state.artifacts.lock().await.remove(&expected_run_id);
2785 return redirect_or_json_ok(want_json, &safe_redirect);
2786 }
2787 }
2788
2789 drop(reg);
2790 let hint = if state.server_mode {
2791 String::new()
2792 } else {
2793 format!(
2794 "\n\nSearched folder : {}\nHTML found : {}",
2795 scan_root.display(),
2796 html_path.display()
2797 )
2798 };
2799 locate_handler_err(
2800 want_json,
2801 format!(
2802 "Could not link this report.\n\n\
2803 No result_*.json was found in the selected folder. \
2804 Make sure you selected the top-level scan output folder \
2805 (the one that contains html/, json/, pdf/ subfolders).{hint}"
2806 ),
2807 &csp_nonce,
2808 )
2809}
2810
2811fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2813 fs::read_dir(dir)
2814 .ok()?
2815 .flatten()
2816 .map(|e| e.path())
2817 .find(|p| {
2818 p.is_file()
2819 && p.file_stem()
2820 .and_then(|n| n.to_str())
2821 .is_some_and(|n| n.starts_with("result"))
2822 && p.extension()
2823 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2824 })
2825}
2826
2827#[derive(Deserialize)]
2828struct LocateReportsDirForm {
2829 folder_path: String,
2830}
2831
2832#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
2834 State(state): State<AppState>,
2835 Form(form): Form<LocateReportsDirForm>,
2836) -> impl IntoResponse {
2837 if state.server_mode {
2838 return StatusCode::NOT_FOUND.into_response();
2839 }
2840 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2841 Ok(p) => strip_unc_prefix(p),
2842 Err(_) => {
2843 return axum::response::Redirect::to(
2844 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2845 )
2846 .into_response();
2847 }
2848 };
2849 if !folder.is_dir() {
2850 return axum::response::Redirect::to(
2851 "/view-reports?error=Selected+path+is+not+a+directory.",
2852 )
2853 .into_response();
2854 }
2855
2856 let candidates = collect_result_json_candidates(&folder);
2857
2858 if candidates.is_empty() {
2859 return axum::response::Redirect::to(
2860 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2861 )
2862 .into_response();
2863 }
2864
2865 let mut linked_count: usize = 0;
2866 let mut reg = state.registry.lock().await;
2867 for json_path in candidates {
2868 let Some(parent) = json_path.parent().map(PathBuf::from) else {
2869 continue;
2870 };
2871 if is_dir_already_registered(®, &parent) {
2872 continue;
2873 }
2874 let Some(entry) = build_registry_entry_from_json(json_path) else {
2875 continue;
2876 };
2877 reg.add_entry(entry);
2878 linked_count += 1;
2879 }
2880 let _ = reg.save(&state.registry_path);
2881 drop(reg);
2882
2883 if linked_count == 0 {
2884 return axum::response::Redirect::to(
2885 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2886 )
2887 .into_response();
2888 }
2889 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2890}
2891
2892#[derive(Deserialize)]
2893struct RelocateScanForm {
2894 run_id: String,
2895 folder_path: String,
2896 redirect_url: String,
2897}
2898
2899fn relocate_folder_err(
2902 want_json: bool,
2903 status: StatusCode,
2904 msg: &str,
2905 run_id: &str,
2906 folder_hint: &str,
2907 redirect_url: &str,
2908 csp_nonce: &str,
2909) -> Response {
2910 if want_json {
2911 (
2912 status,
2913 axum::Json(serde_json::json!({"ok": false, "message": msg})),
2914 )
2915 .into_response()
2916 } else {
2917 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
2918 }
2919}
2920
2921async fn relocate_scan_handler(
2922 State(state): State<AppState>,
2923 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2924 headers: axum::http::HeaderMap,
2925 Form(form): Form<RelocateScanForm>,
2926) -> impl IntoResponse {
2927 let want_json = headers
2928 .get(axum::http::header::ACCEPT)
2929 .and_then(|v| v.to_str().ok())
2930 .is_some_and(|v| v.contains("application/json"));
2931 if state.server_mode {
2932 return StatusCode::NOT_FOUND.into_response();
2933 }
2934
2935 let run_id = form.run_id.trim().to_string();
2936 let redirect_url = form.redirect_url.trim().to_string();
2937
2938 let run_exists = {
2939 let reg = state.registry.lock().await;
2940 reg.find_by_run_id(&run_id).is_some()
2941 };
2942 if !run_exists {
2943 if want_json {
2944 return (
2945 StatusCode::NOT_FOUND,
2946 axum::Json(serde_json::json!({
2947 "ok": false,
2948 "message": format!("Run ID '{run_id}' not found in registry.")
2949 })),
2950 )
2951 .into_response();
2952 }
2953 let html = ErrorTemplate {
2954 message: format!("Run ID '{run_id}' not found in registry."),
2955 last_report_url: Some("/compare-scans".to_string()),
2956 last_report_label: Some("Compare Scans".to_string()),
2957 run_id: Some(run_id.clone()),
2958 error_code: Some(404),
2959 csp_nonce: csp_nonce.clone(),
2960 version: env!("CARGO_PKG_VERSION"),
2961 }
2962 .render()
2963 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2964 return Html(html).into_response();
2965 }
2966
2967 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2968 Ok(p) => strip_unc_prefix(p),
2969 Err(_) => {
2970 return relocate_folder_err(
2971 want_json,
2972 StatusCode::UNPROCESSABLE_ENTITY,
2973 "Folder not found or path is invalid.",
2974 &run_id,
2975 form.folder_path.trim(),
2976 &redirect_url,
2977 &csp_nonce,
2978 );
2979 }
2980 };
2981 if !folder.is_dir() {
2982 return relocate_folder_err(
2983 want_json,
2984 StatusCode::UNPROCESSABLE_ENTITY,
2985 "Selected path is not a directory.",
2986 &run_id,
2987 &folder.display().to_string(),
2988 &redirect_url,
2989 &csp_nonce,
2990 );
2991 }
2992
2993 let json_candidates = find_result_files_by_ext(&folder, "json");
2994 if json_candidates.is_empty() {
2995 let msg = format!(
2996 "No result JSON files found in the selected folder.\nSearched: {}",
2997 folder.display()
2998 );
2999 return relocate_folder_err(
3000 want_json,
3001 StatusCode::UNPROCESSABLE_ENTITY,
3002 &msg,
3003 &run_id,
3004 &folder.display().to_string(),
3005 &redirect_url,
3006 &csp_nonce,
3007 );
3008 }
3009
3010 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3011 let msg = format!(
3012 "No matching scan found in the selected folder.\n\
3013 The JSON files present do not contain run ID: {run_id}\n\
3014 Searched: {}",
3015 folder.display()
3016 );
3017 return relocate_folder_err(
3018 want_json,
3019 StatusCode::UNPROCESSABLE_ENTITY,
3020 &msg,
3021 &run_id,
3022 &folder.display().to_string(),
3023 &redirect_url,
3024 &csp_nonce,
3025 );
3026 };
3027
3028 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3029 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3030 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3031
3032 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3033 redirect_url
3034 } else {
3035 "/compare-scans".to_string()
3036 };
3037 redirect_or_json_ok(want_json, &safe_redirect)
3038}
3039
3040fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3041 let mut out = Vec::new();
3042 collect_scan_files_by_ext(folder, ext, &mut out);
3043 if let Ok(rd) = fs::read_dir(folder) {
3044 for entry in rd.flatten() {
3045 let sub = entry.path();
3046 if sub.is_dir() {
3047 collect_scan_files_by_ext(&sub, ext, &mut out);
3048 }
3049 }
3050 }
3051 out
3052}
3053
3054fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3055 let Ok(rd) = fs::read_dir(dir) else { return };
3056 for entry in rd.flatten() {
3057 let p = entry.path();
3058 if p.is_file()
3059 && p.file_stem()
3060 .and_then(|n| n.to_str())
3061 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3062 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3063 {
3064 out.push(p);
3065 }
3066 }
3067}
3068
3069fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3070 candidates
3071 .iter()
3072 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3073 .cloned()
3074}
3075
3076async fn update_run_file_paths(
3077 state: &AppState,
3078 run_id: &str,
3079 json_path: PathBuf,
3080 html_path: Option<PathBuf>,
3081 pdf_path: Option<PathBuf>,
3082) {
3083 let mut reg = state.registry.lock().await;
3084 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3085 entry.json_path = Some(json_path);
3086 if let Some(hp) = html_path {
3087 entry.html_path = Some(hp);
3088 }
3089 if let Some(pp) = pdf_path {
3090 entry.pdf_path = Some(pp);
3091 }
3092 }
3093 let _ = reg.save(&state.registry_path);
3094}
3095
3096fn missing_scan_relocate_response(
3097 message: &str,
3098 run_id: &str,
3099 folder_hint: &str,
3100 redirect_url: &str,
3101 server_mode: bool,
3102 csp_nonce: &str,
3103) -> axum::response::Response {
3104 let html = RelocateScanTemplate {
3105 message: message.to_string(),
3106 run_id: run_id.to_string(),
3107 folder_hint: folder_hint.to_string(),
3108 redirect_url: redirect_url.to_string(),
3109 server_mode,
3110 csp_nonce: csp_nonce.to_owned(),
3111 version: env!("CARGO_PKG_VERSION"),
3112 }
3113 .render()
3114 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3115 (StatusCode::NOT_FOUND, Html(html)).into_response()
3116}
3117
3118fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3122 let mut candidates = Vec::new();
3123 if let Some(j) = find_result_json_in_dir(folder) {
3124 candidates.push(j);
3125 }
3126 if let Ok(dir_entries) = fs::read_dir(folder) {
3127 for entry in dir_entries.flatten() {
3128 let sub = entry.path();
3129 if sub.is_dir() {
3130 if let Some(j) = find_result_json_in_dir(&sub) {
3131 candidates.push(j);
3132 }
3133 }
3134 }
3135 }
3136 candidates
3137}
3138
3139fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3140 reg.entries.iter().any(|e| {
3141 let dir_match = e
3142 .json_path
3143 .as_ref()
3144 .and_then(|p| p.parent())
3145 .is_some_and(|p| p == parent)
3146 || e.html_path
3147 .as_ref()
3148 .and_then(|p| p.parent())
3149 .is_some_and(|p| p == parent);
3150 dir_match
3151 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3152 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3153 })
3154}
3155
3156fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3157 let parent = json_path.parent()?.to_path_buf();
3158 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3159 rd.flatten()
3160 .map(|e| e.path())
3161 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3162 });
3163 let run = read_json(&json_path).ok()?;
3164 let project_label = run.input_roots.first().map_or_else(
3165 || "Unknown Project".to_string(),
3166 |r| sanitize_project_label(r),
3167 );
3168 Some(RegistryEntry {
3169 run_id: run.tool.run_id.clone(),
3170 timestamp_utc: run.tool.timestamp_utc,
3171 project_label,
3172 input_roots: run.input_roots.clone(),
3173 json_path: Some(json_path),
3174 html_path,
3175 pdf_path: None,
3176 csv_path: None,
3177 xlsx_path: None,
3178 summary: ScanSummarySnapshot {
3179 files_analyzed: run.summary_totals.files_analyzed,
3180 files_skipped: run.summary_totals.files_skipped,
3181 total_physical_lines: run.summary_totals.total_physical_lines,
3182 code_lines: run.summary_totals.code_lines,
3183 comment_lines: run.summary_totals.comment_lines,
3184 blank_lines: run.summary_totals.blank_lines,
3185 functions: run.summary_totals.functions,
3186 classes: run.summary_totals.classes,
3187 variables: run.summary_totals.variables,
3188 imports: run.summary_totals.imports,
3189 test_count: run.summary_totals.test_count,
3190 coverage_lines_found: run.summary_totals.coverage_lines_found,
3191 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3192 coverage_functions_found: run.summary_totals.coverage_functions_found,
3193 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3194 coverage_branches_found: run.summary_totals.coverage_branches_found,
3195 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3196 },
3197 git_branch: run.git_branch.clone(),
3198 git_commit: run.git_commit_short.clone(),
3199 git_author: run.git_commit_author.clone(),
3200 git_tags: run.git_tags.clone(),
3201 git_nearest_tag: run.git_nearest_tag.clone(),
3202 git_commit_date: run.git_commit_date,
3203 })
3204}
3205
3206fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3209 let mut linked = 0usize;
3210 for json_path in collect_result_json_candidates(folder) {
3211 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3212 continue;
3213 };
3214 if is_dir_already_registered(reg, &parent) {
3215 continue;
3216 }
3217 let Some(entry) = build_registry_entry_from_json(json_path) else {
3218 continue;
3219 };
3220 reg.add_entry(entry);
3221 linked += 1;
3222 }
3223 linked
3224}
3225
3226async fn auto_scan_watched_dirs(state: &AppState) {
3228 let dirs: Vec<PathBuf> = {
3229 let wd = state.watched_dirs.lock().await;
3230 wd.dirs.clone()
3231 };
3232 if dirs.is_empty() {
3233 return;
3234 }
3235 let mut reg = state.registry.lock().await;
3236 let mut total = 0usize;
3237 for dir in &dirs {
3238 if dir.is_dir() {
3239 total += scan_folder_into_registry(dir, &mut reg);
3240 }
3241 }
3242 if total > 0 {
3243 let _ = reg.save(&state.registry_path);
3244 }
3245}
3246
3247#[derive(Deserialize)]
3250struct WatchedDirForm {
3251 folder_path: String,
3252 #[serde(default = "default_redirect")]
3253 redirect_to: String,
3254}
3255
3256fn default_redirect() -> String {
3257 "/view-reports".to_string()
3258}
3259
3260#[derive(Deserialize)]
3261struct WatchedDirRefreshForm {
3262 #[serde(default = "default_redirect")]
3263 redirect_to: String,
3264}
3265
3266fn safe_redirect(dest: &str) -> &str {
3270 if dest.starts_with('/') {
3271 dest
3272 } else {
3273 "/"
3274 }
3275}
3276
3277async fn add_watched_dir_handler(
3280 State(state): State<AppState>,
3281 Form(form): Form<WatchedDirForm>,
3282) -> impl IntoResponse {
3283 if state.server_mode {
3284 return StatusCode::NOT_FOUND.into_response();
3285 }
3286 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3287 strip_unc_prefix(p)
3288 } else {
3289 let dest = format!(
3290 "{}?error=Folder+not+found+or+path+is+invalid.",
3291 safe_redirect(&form.redirect_to)
3292 );
3293 return axum::response::Redirect::to(&dest).into_response();
3294 };
3295 if !folder.is_dir() {
3296 let dest = format!(
3297 "{}?error=Selected+path+is+not+a+directory.",
3298 safe_redirect(&form.redirect_to)
3299 );
3300 return axum::response::Redirect::to(&dest).into_response();
3301 }
3302
3303 {
3305 let mut wd = state.watched_dirs.lock().await;
3306 wd.add(folder.clone());
3307 let _ = wd.save(&state.watched_dirs_path);
3308 }
3309
3310 let linked = {
3312 let mut reg = state.registry.lock().await;
3313 let n = scan_folder_into_registry(&folder, &mut reg);
3314 if n > 0 {
3315 let _ = reg.save(&state.registry_path);
3316 }
3317 n
3318 };
3319
3320 let dest = if linked > 0 {
3321 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3322 } else {
3323 format!(
3324 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3325 safe_redirect(&form.redirect_to)
3326 )
3327 };
3328 axum::response::Redirect::to(&dest).into_response()
3329}
3330
3331async fn remove_watched_dir_handler(
3332 State(state): State<AppState>,
3333 Form(form): Form<WatchedDirForm>,
3334) -> impl IntoResponse {
3335 if state.server_mode {
3336 return StatusCode::NOT_FOUND.into_response();
3337 }
3338 let folder = PathBuf::from(&form.folder_path);
3339 {
3340 let mut wd = state.watched_dirs.lock().await;
3341 wd.remove(&folder);
3342 let _ = wd.save(&state.watched_dirs_path);
3343 }
3344 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3345}
3346
3347async fn refresh_watched_dirs_handler(
3348 State(state): State<AppState>,
3349 Form(form): Form<WatchedDirRefreshForm>,
3350) -> impl IntoResponse {
3351 if state.server_mode {
3352 return StatusCode::NOT_FOUND.into_response();
3353 }
3354 let dirs: Vec<PathBuf> = {
3355 let wd = state.watched_dirs.lock().await;
3356 wd.dirs.clone()
3357 };
3358 let mut total = 0usize;
3359 {
3360 let mut reg = state.registry.lock().await;
3361 for dir in &dirs {
3362 if dir.is_dir() {
3363 total += scan_folder_into_registry(dir, &mut reg);
3364 }
3365 }
3366 if total > 0 {
3367 let _ = reg.save(&state.registry_path);
3368 }
3369 }
3370 let dest = if total > 0 {
3371 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3372 } else {
3373 safe_redirect(&form.redirect_to).to_owned()
3374 };
3375 axum::response::Redirect::to(&dest).into_response()
3376}
3377
3378#[derive(Debug, Deserialize)]
3379struct OpenPathQuery {
3380 path: Option<String>,
3381}
3382
3383fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3384 let mut ancestor = std::path::Path::new(raw);
3385 loop {
3386 match ancestor.parent() {
3387 Some(p) => {
3388 ancestor = p;
3389 if ancestor.is_dir() {
3390 break;
3391 }
3392 }
3393 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3394 }
3395 }
3396 Ok(ancestor.to_path_buf())
3397}
3398
3399async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3400 match tokio::fs::canonicalize(raw).await {
3401 Ok(canonical) if canonical.is_file() => match canonical.parent() {
3402 Some(p) => Ok(p.to_path_buf()),
3403 None => Err((StatusCode::BAD_REQUEST, "path has no parent")),
3404 },
3405 Ok(canonical) if canonical.is_dir() => Ok(canonical),
3406 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3407 Err(_) => find_existing_ancestor(raw),
3408 }
3409}
3410
3411async fn open_path_handler(
3412 State(state): State<AppState>,
3413 Query(query): Query<OpenPathQuery>,
3414) -> impl IntoResponse {
3415 if state.server_mode {
3416 return Json(serde_json::json!({
3417 "server_mode_disabled": true,
3418 "message": "Opening a path in the file manager is only available in local desktop mode."
3419 }))
3420 .into_response();
3421 }
3422 if std::env::var("SLOC_HEADLESS").is_ok() {
3424 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3425 }
3426 let raw = match query.path.as_deref() {
3427 Some(p) if !p.is_empty() => p,
3428 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3429 };
3430
3431 let target = match resolve_open_target(raw).await {
3435 Ok(p) => p,
3436 Err((code, msg)) => return (code, msg).into_response(),
3437 };
3438
3439 #[cfg(target_os = "windows")]
3440 win_dialog_focus::open_folder_foreground(target);
3441 #[cfg(target_os = "macos")]
3442 let _ = std::process::Command::new("open")
3443 .arg(&target)
3444 .stdout(Stdio::null())
3445 .stderr(Stdio::null())
3446 .spawn();
3447 #[cfg(target_os = "linux")]
3448 {
3449 let folder_name = target
3450 .file_name()
3451 .and_then(|n| n.to_str())
3452 .map(str::to_owned);
3453 let _ = std::process::Command::new("xdg-open")
3454 .arg(&target)
3455 .stdout(Stdio::null())
3456 .stderr(Stdio::null())
3457 .spawn();
3458 if let Some(name) = folder_name {
3462 std::thread::spawn(move || {
3463 std::thread::sleep(std::time::Duration::from_millis(800));
3464 let _ = std::process::Command::new("wmctrl")
3465 .args(["-a", &name])
3466 .stdout(Stdio::null())
3467 .stderr(Stdio::null())
3468 .spawn();
3469 });
3470 }
3471 }
3472
3473 Json(serde_json::json!({"ok": true})).into_response()
3474}
3475
3476async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3477 let (content_type, bytes): (&'static str, &'static [u8]) =
3478 match (folder.as_str(), file.as_str()) {
3479 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3480 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3481 ("icons", "c.png") => ("image/png", IMG_ICON_C),
3482 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3483 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3484 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3485 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3486 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3487 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3488 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3489 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3490 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3491 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3492 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3493 ("icons", "r.png") => ("image/png", IMG_ICON_R),
3494 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3495 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3496 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3497 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3498 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3499 _ => return StatusCode::NOT_FOUND.into_response(),
3500 };
3501 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3502}
3503
3504async fn preview_handler(
3505 State(state): State<AppState>,
3506 Query(query): Query<PreviewQuery>,
3507) -> impl IntoResponse {
3508 let raw_path = query
3509 .path
3510 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3511 let resolved = resolve_input_path(&raw_path);
3512
3513 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3517 return Html(
3518 r#"<div class="preview-error">Sample directory not available on this server.
3519 Enter a path to a project directory or upload files using Browse.</div>"#
3520 .to_string(),
3521 );
3522 }
3523
3524 if state.server_mode {
3525 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3526 if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3528 let config = &state.base_config;
3529 if config.discovery.allowed_scan_roots.is_empty() {
3530 return Html(
3531 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3532 );
3533 }
3534 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3535 fs::canonicalize(root)
3536 .ok()
3537 .is_some_and(|r| canonical.starts_with(&r))
3538 });
3539 if !allowed {
3540 return Html(
3541 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3542 );
3543 }
3544 }
3545 }
3546
3547 let include_patterns = split_patterns(query.include_globs.as_deref());
3548 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3549
3550 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3551 Ok(html) => Html(html),
3552 Err(err) => Html(format!(
3553 r#"<div class="preview-error">Preview failed: {}</div>"#,
3554 escape_html(&err.to_string())
3555 )),
3556 }
3557}
3558
3559#[derive(Debug, Deserialize, Default)]
3560struct SuggestCoverageQuery {
3561 path: Option<String>,
3562}
3563
3564#[derive(Serialize)]
3565struct SuggestCoverageResponse {
3566 found: Option<String>,
3567 tool: Option<&'static str>,
3568 hint: Option<&'static str>,
3569}
3570
3571async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3572 const CANDIDATES: &[&str] = &[
3573 "coverage/lcov.info",
3575 "lcov.info",
3576 "target/llvm-cov/lcov.info",
3577 "target/coverage/lcov.info",
3578 "target/debug/coverage/lcov.info",
3579 "coverage/coverage.lcov",
3580 "build/coverage/lcov.info",
3581 "reports/lcov.info",
3582 "coverage.xml",
3584 "coverage/coverage.xml",
3585 "target/site/cobertura/coverage.xml",
3586 "build/reports/coverage/coverage.xml",
3587 "target/site/jacoco/jacoco.xml",
3589 "build/reports/jacoco/test/jacocoTestReport.xml",
3590 "build/reports/jacoco/jacocoTestReport.xml",
3591 "build/jacoco/jacoco.xml",
3592 ];
3593 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3594 let found = CANDIDATES
3595 .iter()
3596 .map(|rel| root.join(rel))
3597 .find(|p| p.is_file())
3598 .map(|p| display_path(&p));
3599
3600 let (tool, hint) = detect_coverage_tool(&root);
3601 Json(SuggestCoverageResponse { found, tool, hint })
3602}
3603
3604fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3607 if root.join("Cargo.toml").is_file() {
3608 return (
3609 Some("cargo-llvm-cov"),
3610 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3611 );
3612 }
3613 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3614 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3615 }
3616 if root.join("pom.xml").is_file() {
3617 return (Some("jacoco"), Some("mvn test jacoco:report"));
3618 }
3619 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3620 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3621 }
3622 (None, None)
3623}
3624
3625#[allow(clippy::result_large_err)]
3627fn validate_server_scan_path(
3628 config: &sloc_config::AppConfig,
3629 resolved_path: &Path,
3630 csp_nonce: &str,
3631) -> Result<(), Response> {
3632 if config.discovery.allowed_scan_roots.is_empty() {
3633 let template = ErrorTemplate {
3634 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3635 Set allowed_scan_roots in the server config to permit scanning."
3636 .to_string(),
3637 last_report_url: None,
3638 last_report_label: None,
3639 run_id: None,
3640 error_code: Some(403),
3641 csp_nonce: csp_nonce.to_owned(),
3642 version: env!("CARGO_PKG_VERSION"),
3643 };
3644 return Err((
3645 StatusCode::FORBIDDEN,
3646 Html(
3647 template
3648 .render()
3649 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3650 ),
3651 )
3652 .into_response());
3653 }
3654 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3655 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3656 fs::canonicalize(root)
3657 .ok()
3658 .is_some_and(|r| canonical.starts_with(&r))
3659 });
3660 if !allowed {
3661 tracing::warn!(event = "path_rejected", path = %canonical.display(),
3662 "Scan path not in allowed_scan_roots");
3663 let template = ErrorTemplate {
3664 message: "The requested path is not within an allowed scan directory.".to_string(),
3665 last_report_url: None,
3666 last_report_label: None,
3667 run_id: None,
3668 error_code: Some(403),
3669 csp_nonce: csp_nonce.to_owned(),
3670 version: env!("CARGO_PKG_VERSION"),
3671 };
3672 return Err((
3673 StatusCode::FORBIDDEN,
3674 Html(
3675 template
3676 .render()
3677 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3678 ),
3679 )
3680 .into_response());
3681 }
3682 Ok(())
3683}
3684
3685fn apply_output_dir_exclusions(
3687 config: &mut sloc_config::AppConfig,
3688 project_path: &str,
3689 raw_output_dir: &str,
3690) {
3691 let project_root = resolve_input_path(project_path);
3692 let raw_out = raw_output_dir.trim();
3693 let resolved_out = if raw_out.is_empty() {
3694 project_root.join("sloc")
3695 } else if Path::new(raw_out).is_absolute() {
3696 PathBuf::from(raw_out)
3697 } else {
3698 workspace_root().join(raw_out)
3699 };
3700 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3701 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3702 let dir = first.to_string();
3703 if !config.discovery.excluded_directories.contains(&dir) {
3704 config.discovery.excluded_directories.push(dir);
3705 }
3706 }
3707 }
3708 if !config
3709 .discovery
3710 .excluded_directories
3711 .iter()
3712 .any(|d| d == "sloc")
3713 {
3714 config
3715 .discovery
3716 .excluded_directories
3717 .push("sloc".to_string());
3718 }
3719}
3720
3721const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3723 ScanSummarySnapshot {
3724 files_analyzed: run.summary_totals.files_analyzed,
3725 files_skipped: run.summary_totals.files_skipped,
3726 total_physical_lines: run.summary_totals.total_physical_lines,
3727 code_lines: run.summary_totals.code_lines,
3728 comment_lines: run.summary_totals.comment_lines,
3729 blank_lines: run.summary_totals.blank_lines,
3730 functions: run.summary_totals.functions,
3731 classes: run.summary_totals.classes,
3732 variables: run.summary_totals.variables,
3733 imports: run.summary_totals.imports,
3734 test_count: run.summary_totals.test_count,
3735 coverage_lines_found: run.summary_totals.coverage_lines_found,
3736 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3737 coverage_functions_found: run.summary_totals.coverage_functions_found,
3738 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3739 coverage_branches_found: run.summary_totals.coverage_branches_found,
3740 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3741 }
3742}
3743
3744pub(crate) fn build_run_registry_entry(
3746 run: &AnalysisRun,
3747 run_id: &str,
3748 project_label: &str,
3749 artifacts: &RunArtifacts,
3750) -> RegistryEntry {
3751 RegistryEntry {
3752 run_id: run_id.to_owned(),
3753 timestamp_utc: run.tool.timestamp_utc,
3754 project_label: project_label.to_owned(),
3755 input_roots: run.input_roots.clone(),
3756 json_path: artifacts.json_path.clone(),
3757 html_path: artifacts.html_path.clone(),
3758 pdf_path: artifacts.pdf_path.clone(),
3759 csv_path: artifacts.csv_path.clone(),
3760 xlsx_path: artifacts.xlsx_path.clone(),
3761 summary: summary_snapshot_from_run(run),
3762 git_branch: run.git_branch.clone(),
3763 git_commit: run.git_commit_short.clone(),
3764 git_author: run.git_commit_author.clone(),
3765 git_tags: run.git_tags.clone(),
3766 git_nearest_tag: run.git_nearest_tag.clone(),
3767 git_commit_date: run.git_commit_date.clone(),
3768 }
3769}
3770
3771fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3773 if let Some(policy) = form.mixed_line_policy {
3774 config.analysis.mixed_line_policy = policy;
3775 }
3776 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3777 config.analysis.generated_file_detection =
3778 form.generated_file_detection.as_deref() != Some("disabled");
3779 config.analysis.minified_file_detection =
3780 form.minified_file_detection.as_deref() != Some("disabled");
3781 config.analysis.vendor_directory_detection =
3782 form.vendor_directory_detection.as_deref() != Some("disabled");
3783 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3784 if let Some(binary_behavior) = form.binary_file_behavior {
3785 config.analysis.binary_file_behavior = binary_behavior;
3786 }
3787 apply_report_opts(config, form);
3788 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3789 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3790 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3791 if let Some(policy) = form.continuation_line_policy {
3792 config.analysis.continuation_line_policy = policy;
3793 }
3794 if let Some(policy) = form.blank_in_block_comment_policy {
3795 config.analysis.blank_in_block_comment_policy = policy;
3796 }
3797 config.analysis.count_compiler_directives =
3798 form.count_compiler_directives.as_deref() != Some("disabled");
3799 apply_style_threshold(config, form);
3800 apply_coverage_path(config, form);
3801}
3802
3803fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3804 if let Some(report_title) = form.report_title.as_deref() {
3805 let trimmed = report_title.trim();
3806 if !trimmed.is_empty() {
3807 config.reporting.report_title = trimmed.to_string();
3808 }
3809 }
3810 if let Some(hf) = form.report_header_footer.as_deref() {
3811 let trimmed = hf.trim();
3812 config.reporting.report_header_footer = if trimmed.is_empty() {
3813 None
3814 } else {
3815 Some(trimmed.to_string())
3816 };
3817 }
3818}
3819
3820fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3821 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
3822 if let Ok(t) = threshold_str.parse::<u16>() {
3823 if t == 80 || t == 100 || t == 120 {
3824 config.analysis.style_col_threshold = t;
3825 }
3826 }
3827 }
3828 if let Some(v) = form.style_analysis_enabled.as_deref() {
3829 config.analysis.style_analysis_enabled = v != "disabled";
3830 }
3831 if let Some(v) = form.style_score_threshold.as_deref() {
3832 if let Ok(t) = v.parse::<u8>() {
3833 config.analysis.style_score_threshold = t.min(100);
3834 }
3835 }
3836 if let Some(v) = form.style_lang_scope.as_deref() {
3837 let scope = v.trim();
3838 if scope == "c_family" || scope == "all" {
3839 config.analysis.style_lang_scope = scope.to_string();
3840 }
3841 }
3842}
3843
3844fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3845 if let Some(cov) = &form.coverage_file {
3846 let trimmed = cov.trim();
3847 if !trimmed.is_empty() {
3848 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3849 }
3850 }
3851}
3852
3853fn spawn_pdf_background(
3857 pending_pdf: PendingPdf,
3858 run_id: String,
3859 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3860) {
3861 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3862 tokio::spawn(async move {
3863 let result = tokio::task::spawn_blocking(move || {
3864 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3865 if cleanup_src {
3866 let _ = fs::remove_file(&pdf_src);
3867 }
3868 r
3869 })
3870 .await;
3871 let failed = match result {
3872 Ok(Ok(())) => false,
3873 Ok(Err(err)) => {
3874 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3875 true
3876 }
3877 Err(err) => {
3878 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3879 true
3880 }
3881 };
3882 if failed {
3883 let mut map = artifacts.lock().await;
3884 if let Some(entry) = map.get_mut(&run_id) {
3885 entry.pdf_path = None;
3886 }
3887 }
3888 });
3889 }
3890}
3891
3892fn spawn_native_pdf_background(
3896 json_path: PathBuf,
3897 pdf_dest: PathBuf,
3898 run_id: String,
3899 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3900) {
3901 tokio::spawn(async move {
3902 let result = tokio::task::spawn_blocking(move || {
3903 let run = sloc_core::read_json(&json_path)?;
3904 write_pdf_from_run(&run, &pdf_dest)
3905 })
3906 .await;
3907 let failed = match result {
3908 Ok(Ok(())) => false,
3909 Ok(Err(err)) => {
3910 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
3911 true
3912 }
3913 Err(err) => {
3914 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
3915 true
3916 }
3917 };
3918 if failed {
3919 let mut map = artifacts.lock().await;
3920 if let Some(entry) = map.get_mut(&run_id) {
3921 entry.pdf_path = None;
3922 }
3923 }
3924 });
3925}
3926
3927fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3929 cmp.file_deltas
3930 .iter()
3931 .map(|f| match f.status {
3932 FileChangeStatus::Added => f.current_code,
3933 FileChangeStatus::Modified => f.code_delta.max(0),
3934 _ => 0,
3935 })
3936 .sum()
3937}
3938
3939fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3941 cmp.file_deltas
3942 .iter()
3943 .map(|f| match f.status {
3944 FileChangeStatus::Removed => f.baseline_code,
3945 FileChangeStatus::Modified => (-f.code_delta).max(0),
3946 _ => 0,
3947 })
3948 .sum()
3949}
3950
3951fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3953 cmp.file_deltas
3954 .iter()
3955 .filter(|f| f.status == FileChangeStatus::Unchanged)
3956 .map(|f| f.current_code)
3957 .sum()
3958}
3959
3960fn build_submodule_row(
3962 s: &sloc_core::SubmoduleSummary,
3963 run: &AnalysisRun,
3964 run_id: &str,
3965 run_dir: &Path,
3966) -> SubmoduleRow {
3967 let safe = sanitize_project_label(&s.name);
3968 let artifact_key = format!("sub_{safe}");
3969 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
3970 let parent_path = run
3971 .input_roots
3972 .first()
3973 .map_or("", std::string::String::as_str);
3974 let sub_run = build_sub_run(run, s, parent_path);
3975 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
3976 let sub_dir = run_dir.join("submodules");
3977 let _ = fs::create_dir_all(&sub_dir);
3978 let path = sub_dir.join(format!("{artifact_key}.html"));
3979 if fs::write(&path, sub_html.as_bytes()).is_ok() {
3980 Some(format!("/runs/{artifact_key}/{run_id}"))
3981 } else {
3982 None
3983 }
3984 })
3985 } else {
3986 None
3987 };
3988 SubmoduleRow {
3989 name: s.name.clone(),
3990 relative_path: s.relative_path.clone(),
3991 files_analyzed: s.files_analyzed,
3992 code_lines: s.code_lines,
3993 comment_lines: s.comment_lines,
3994 blank_lines: s.blank_lines,
3995 total_physical_lines: s.total_physical_lines,
3996 html_url,
3997 }
3998}
3999
4000#[allow(clippy::similar_names)]
4003#[allow(clippy::significant_drop_tightening)] async fn analyze_handler(
4005 State(state): State<AppState>,
4006 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4007 Form(form): Form<AnalyzeForm>,
4008) -> impl IntoResponse {
4009 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4010 let template = ErrorTemplate {
4011 message: format!(
4012 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4013 Please wait a moment and try again."
4014 ),
4015 last_report_url: None,
4016 last_report_label: None,
4017 run_id: None,
4018 error_code: Some(503),
4019 csp_nonce: csp_nonce.clone(),
4020 version: env!("CARGO_PKG_VERSION"),
4021 };
4022 return (
4023 StatusCode::SERVICE_UNAVAILABLE,
4024 Html(
4025 template
4026 .render()
4027 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4028 ),
4029 )
4030 .into_response();
4031 };
4032
4033 let mut config = state.base_config.clone();
4034
4035 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4036 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4037 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4038
4039 if !is_git_mode {
4040 let resolved_path = resolve_input_path(&form.path);
4041 if state.server_mode
4042 && !is_upload_tmp_path(&resolved_path)
4043 && !is_sample_path(&resolved_path)
4044 {
4045 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4046 return resp;
4047 }
4048 }
4049 config.discovery.root_paths = vec![resolved_path];
4050 }
4051
4052 apply_form_to_config(&mut config, &form);
4053 apply_output_dir_exclusions(
4054 &mut config,
4055 &form.path,
4056 form.output_dir.as_deref().unwrap_or(""),
4057 );
4058
4059 let wait_id = uuid::Uuid::new_v4().to_string();
4061 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4062
4063 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4065 let task_cancel = Arc::clone(&cancel_token);
4066
4067 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4069 let task_phase = Arc::clone(&phase);
4070
4071 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4072 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4073 let task_files_done = Arc::clone(&files_done);
4074 let task_files_total = Arc::clone(&files_total);
4075
4076 {
4079 let mut runs = state.async_runs.lock().await;
4080 runs.insert(
4081 wait_id.clone(),
4082 AsyncRunState::Running {
4083 started_at: std::time::Instant::now(),
4084 cancel_token,
4085 phase,
4086 files_done,
4087 files_total,
4088 },
4089 );
4090 }
4091
4092 let task = AnalysisTask {
4093 sem_permit,
4094 state: state.clone(),
4095 wait_id: wait_id.clone(),
4096 config,
4097 cancel: task_cancel,
4098 phase: task_phase,
4099 files_done: task_files_done,
4100 files_total: task_files_total,
4101 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4102 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4103 project_path: form.path.clone(),
4104 output_dir: if state.server_mode {
4108 None
4109 } else {
4110 form.output_dir.clone()
4111 },
4112 clones_dir: state.git_clones_dir.clone(),
4113 };
4114
4115 tokio::spawn(run_analysis_task(task));
4116
4117 let template = ScanWaitTemplate {
4118 version: env!("CARGO_PKG_VERSION"),
4119 wait_id_json,
4120 project_path: form.path.clone(),
4121 csp_nonce,
4122 };
4123 let html = template
4124 .render()
4125 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4126 let mut response = Html(html).into_response();
4127 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4128 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4129 response.headers_mut().insert(name, val);
4130 }
4131 }
4132 response
4133}
4134
4135struct AnalysisTask {
4136 sem_permit: tokio::sync::OwnedSemaphorePermit,
4137 state: AppState,
4138 wait_id: String,
4139 config: AppConfig,
4140 cancel: Arc<std::sync::atomic::AtomicBool>,
4141 phase: Arc<std::sync::Mutex<String>>,
4142 files_done: Arc<std::sync::atomic::AtomicUsize>,
4143 files_total: Arc<std::sync::atomic::AtomicUsize>,
4144 git_repo: Option<String>,
4145 git_ref: Option<String>,
4146 project_path: String,
4147 output_dir: Option<String>,
4148 clones_dir: PathBuf,
4149}
4150
4151#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
4153 let _permit = task.sem_permit;
4154
4155 let cancel_sb = Arc::clone(&task.cancel);
4156 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4157 let clones_dir_sb = task.clones_dir;
4158 let upload_staging_root = task
4160 .config
4161 .discovery
4162 .root_paths
4163 .first()
4164 .filter(|p| is_upload_tmp_path(p))
4165 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4166 .map(PathBuf::from);
4167 let config_sb = task.config;
4168 let progress_sb = sloc_core::ProgressCounters {
4169 files_done: Arc::clone(&task.files_done),
4170 files_total: Arc::clone(&task.files_total),
4171 };
4172 if let Ok(mut p) = task.phase.lock() {
4173 *p = "Scanning files".to_string();
4174 }
4175 let analysis_result = tokio::task::spawn_blocking(move || {
4176 run_analysis_blocking(
4177 config_sb,
4178 git_repo_sb,
4179 git_ref_sb,
4180 clones_dir_sb,
4181 cancel_sb,
4182 Some(progress_sb),
4183 )
4184 })
4185 .await
4186 .map_err(|err| anyhow::anyhow!(err.to_string()))
4187 .and_then(|result| result);
4188
4189 if let Ok(mut p) = task.phase.lock() {
4190 *p = "Writing reports".to_string();
4191 }
4192
4193 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4195 let mut runs = task.state.async_runs.lock().await;
4196 if matches!(
4198 runs.get(&task.wait_id),
4199 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4200 ) {
4201 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4202 }
4203 drop(runs);
4204 return;
4205 }
4206
4207 let run = match analysis_result {
4208 Ok(v) => v,
4209 Err(err) => {
4210 if err.to_string().contains("analysis cancelled") {
4212 let mut runs = task.state.async_runs.lock().await;
4213 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4214 drop(runs);
4215 return;
4216 }
4217 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4218 let mut runs = task.state.async_runs.lock().await;
4219 runs.insert(
4220 task.wait_id.clone(),
4221 AsyncRunState::Failed {
4222 message: "Analysis failed. Check that the path exists and is readable."
4223 .to_string(),
4224 },
4225 );
4226 drop(runs);
4227 return;
4228 }
4229 };
4230
4231 let run_id = run.tool.run_id.clone();
4232 tracing::info!(event = "scan_complete", run_id = %run_id,
4233 path = %task.project_path, files = run.summary_totals.files_analyzed,
4234 "Analysis finished");
4235
4236 let prev_entry: Option<RegistryEntry> = {
4237 let reg = task.state.registry.lock().await;
4238 reg.entries_for_roots(&run.input_roots)
4239 .into_iter()
4240 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4241 .cloned()
4242 };
4243
4244 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4245 prev.json_path
4246 .as_ref()
4247 .and_then(|p| read_json(p).ok())
4248 .map(|prev_run| compute_delta(&prev_run, &run))
4249 });
4250 let prev_scan_count: usize = {
4251 let reg = task.state.registry.lock().await;
4252 reg.entries_for_roots(&run.input_roots)
4253 .iter()
4254 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4255 .count()
4256 };
4257
4258 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4261 .as_ref()
4262 .zip(prev_entry.as_ref())
4263 .map(|(cmp, prev)| ReportDeltaContext {
4264 delta_code_added: sum_added_code_lines(cmp),
4265 delta_code_removed: sum_removed_code_lines(cmp),
4266 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4267 delta_files_added: cmp.files_added,
4268 delta_files_removed: cmp.files_removed,
4269 delta_files_modified: cmp.files_modified,
4270 delta_files_unchanged: cmp.files_unchanged,
4271 prev_code_lines: prev.summary.code_lines,
4272 prev_scan_count: prev_scan_count + 1,
4273 prev_scan_label: fmt_la_time(prev.timestamp_utc),
4274 prev_run_id: Some(prev.run_id.clone()),
4275 current_run_id: Some(run_id.clone()),
4276 });
4277 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4278 Ok(h) => h,
4279 Err(err) => {
4280 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4281 let mut runs = task.state.async_runs.lock().await;
4282 runs.insert(
4283 task.wait_id.clone(),
4284 AsyncRunState::Failed {
4285 message: "Failed to render HTML report.".to_string(),
4286 },
4287 );
4288 drop(runs);
4289 return;
4290 }
4291 };
4292
4293 let output_root = resolve_output_root(task.output_dir.as_deref());
4294 let project_label = derive_project_label(
4295 task.git_repo.as_deref(),
4296 task.git_ref.as_deref(),
4297 &task.project_path,
4298 );
4299 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4300 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4301
4302 let result_context = RunResultContext {
4303 prev_entry: prev_entry.clone(),
4304 prev_scan_count,
4305 project_path: task.project_path.clone(),
4306 };
4307
4308 let artifact_result = persist_run_artifacts(
4309 &run,
4310 &report_html,
4311 &run_dir,
4312 &run.effective_configuration.reporting.report_title,
4313 &file_stem,
4314 result_context,
4315 );
4316
4317 let (artifacts, pending_pdf) = match artifact_result {
4318 Ok(v) => v,
4319 Err(err) => {
4320 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4321 let mut runs = task.state.async_runs.lock().await;
4322 runs.insert(
4323 task.wait_id.clone(),
4324 AsyncRunState::Failed {
4325 message: "Failed to save report artifacts. Check available disk space."
4326 .to_string(),
4327 },
4328 );
4329 drop(runs);
4330 return;
4331 }
4332 };
4333
4334 {
4335 let mut map = task.state.artifacts.lock().await;
4336 map.insert(run_id.clone(), artifacts.clone());
4337 }
4338
4339 {
4340 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4341 let mut reg = task.state.registry.lock().await;
4342 reg.add_entry(entry);
4343 let _ = reg.save(&task.state.registry_path);
4344 }
4345
4346 if let Some(ref cfg_path) = artifacts.scan_config_path {
4347 save_scan_config_json(
4348 cfg_path,
4349 &run,
4350 &task.project_path,
4351 task.output_dir.as_deref(),
4352 );
4353 }
4354
4355 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4356
4357 prom_runs_total().inc();
4358
4359 let mut runs = task.state.async_runs.lock().await;
4361 runs.insert(
4362 task.wait_id.clone(),
4363 AsyncRunState::Complete {
4364 run_id: run_id.clone(),
4365 },
4366 );
4367 drop(runs);
4368
4369 if let Some(staging) = upload_staging_root {
4372 let _ = tokio::fs::remove_dir_all(staging).await;
4373 }
4374
4375 let _ = scan_delta;
4376}
4377
4378fn save_scan_config_json(
4379 cfg_path: &std::path::Path,
4380 run: &sloc_core::AnalysisRun,
4381 project_path: &str,
4382 output_dir: Option<&str>,
4383) {
4384 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4385 .ok()
4386 .and_then(|v| v.as_str().map(String::from))
4387 .unwrap_or_else(|| "code_only".to_string());
4388 let behavior_str =
4389 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4390 .ok()
4391 .and_then(|v| v.as_str().map(String::from))
4392 .unwrap_or_else(|| "skip".to_string());
4393 let scan_cfg = ScanConfig {
4394 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4395 path: project_path.to_string(),
4396 include_globs: run
4397 .effective_configuration
4398 .discovery
4399 .include_globs
4400 .join("\n"),
4401 exclude_globs: run
4402 .effective_configuration
4403 .discovery
4404 .exclude_globs
4405 .join("\n"),
4406 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4407 mixed_line_policy: policy_str,
4408 python_docstrings_as_comments: run
4409 .effective_configuration
4410 .analysis
4411 .python_docstrings_as_comments,
4412 generated_file_detection: run
4413 .effective_configuration
4414 .analysis
4415 .generated_file_detection,
4416 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4417 vendor_directory_detection: run
4418 .effective_configuration
4419 .analysis
4420 .vendor_directory_detection,
4421 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4422 binary_file_behavior: behavior_str,
4423 output_dir: output_dir.unwrap_or("").to_string(),
4424 report_title: run.effective_configuration.reporting.report_title.clone(),
4425 };
4426 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4427 let _ = std::fs::write(cfg_path, json);
4428 }
4429}
4430
4431#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
4433 mut config: AppConfig,
4434 git_repo: Option<String>,
4435 git_ref: Option<String>,
4436 clones_dir: PathBuf,
4437 cancel: Arc<std::sync::atomic::AtomicBool>,
4438 progress: Option<sloc_core::ProgressCounters>,
4439) -> Result<sloc_core::AnalysisRun> {
4440 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4441 let dest = git_clone_dest(&repo, &clones_dir);
4442 sloc_git::clone_or_fetch(&repo, &dest)?;
4443 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4444 sloc_git::create_worktree(&dest, &refname, &wt)?;
4445 config.discovery.root_paths = vec![wt.clone()];
4446 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4447 let _ = sloc_git::destroy_worktree(&dest, &wt);
4448 let mut run = run?;
4449 if run.git_branch.is_none() {
4450 run.git_branch = Some(refname);
4451 }
4452 return Ok(run);
4453 }
4454 analyze(&config, "serve", Some(&cancel), progress.as_ref())
4455}
4456
4457fn derive_project_label(
4458 git_repo: Option<&str>,
4459 git_ref: Option<&str>,
4460 fallback_path: &str,
4461) -> String {
4462 match (
4463 git_repo.filter(|s| !s.is_empty()),
4464 git_ref.filter(|s| !s.is_empty()),
4465 ) {
4466 (Some(repo), Some(refname)) => {
4467 let repo_name = repo
4468 .trim_end_matches('/')
4469 .trim_end_matches(".git")
4470 .rsplit('/')
4471 .next()
4472 .unwrap_or("repo");
4473 sanitize_project_label(&format!("{repo_name}_{refname}"))
4474 }
4475 _ => sanitize_project_label(fallback_path),
4476 }
4477}
4478
4479fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4480 let commit = commit_short.unwrap_or("").trim();
4481 if commit.is_empty() {
4482 project_label.to_string()
4483 } else {
4484 format!("{project_label}_{commit}")
4485 }
4486}
4487
4488#[derive(Serialize)]
4491#[serde(tag = "state", rename_all = "snake_case")]
4492enum AsyncRunStatusResponse {
4493 Running {
4494 elapsed_secs: u64,
4495 phase: String,
4496 files_done: u64,
4497 files_total: u64,
4498 },
4499 Complete {
4500 run_id: String,
4501 },
4502 Failed {
4503 message: String,
4504 },
4505 Cancelled,
4506}
4507
4508async fn async_run_status_handler(
4509 State(state): State<AppState>,
4510 AxumPath(wait_id): AxumPath<String>,
4511) -> Response {
4512 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4514 return error::bad_request("invalid wait_id");
4515 }
4516 let run_state = {
4517 let runs = state.async_runs.lock().await;
4518 runs.get(&wait_id).cloned()
4519 };
4520 match run_state {
4521 None => error::not_found("run not found"),
4522 Some(AsyncRunState::Running {
4523 started_at,
4524 phase,
4525 files_done,
4526 files_total,
4527 ..
4528 }) => {
4529 if started_at.elapsed() > std::time::Duration::from_hours(2) {
4531 let mut runs = state.async_runs.lock().await;
4532 runs.insert(
4533 wait_id,
4534 AsyncRunState::Failed {
4535 message: "Analysis timed out after 2 hours.".to_string(),
4536 },
4537 );
4538 drop(runs);
4539 return Json(AsyncRunStatusResponse::Failed {
4540 message: "Analysis timed out after 2 hours.".to_string(),
4541 })
4542 .into_response();
4543 }
4544 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4545 Json(AsyncRunStatusResponse::Running {
4546 elapsed_secs: started_at.elapsed().as_secs(),
4547 phase: phase_str,
4548 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4549 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4550 })
4551 .into_response()
4552 }
4553 Some(AsyncRunState::Complete { run_id }) => {
4554 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4555 }
4556 Some(AsyncRunState::Failed { message }) => {
4557 Json(AsyncRunStatusResponse::Failed { message }).into_response()
4558 }
4559 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4560 }
4561}
4562
4563async fn cancel_run_handler(
4564 State(state): State<AppState>,
4565 AxumPath(wait_id): AxumPath<String>,
4566) -> Response {
4567 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4568 return error::bad_request("invalid wait_id");
4569 }
4570 let mut runs = state.async_runs.lock().await;
4571 let resp = match runs.get(&wait_id) {
4572 Some(AsyncRunState::Running { cancel_token, .. }) => {
4573 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4574 runs.insert(wait_id, AsyncRunState::Cancelled);
4575 StatusCode::OK.into_response()
4576 }
4577 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4578 _ => error::not_found("run not found"),
4579 };
4580 drop(runs);
4581 resp
4582}
4583
4584async fn async_run_result_handler(
4585 State(state): State<AppState>,
4586 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4587 AxumPath(run_id): AxumPath<String>,
4588) -> Response {
4589 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4590 return StatusCode::BAD_REQUEST.into_response();
4591 }
4592
4593 let artifacts = {
4594 let map = state.artifacts.lock().await;
4595 map.get(&run_id).cloned()
4596 };
4597 let artifacts = if let Some(a) = artifacts {
4598 a
4599 } else {
4600 let reg = state.registry.lock().await;
4601 if let Some(entry) = reg.find_by_run_id(&run_id) {
4602 recover_artifacts_from_registry(entry)
4603 } else {
4604 let html = ErrorTemplate {
4605 message: format!(
4606 "Report not found. Run ID {} is not in the scan history.",
4607 &run_id[..run_id.len().min(8)]
4608 ),
4609 last_report_url: Some("/view-reports".to_string()),
4610 last_report_label: Some("View Reports".to_string()),
4611 run_id: Some(run_id.clone()),
4612 error_code: Some(404),
4613 csp_nonce: csp_nonce.clone(),
4614 version: env!("CARGO_PKG_VERSION"),
4615 }
4616 .render()
4617 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4618 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4619 }
4620 };
4621
4622 let json_path = if let Some(p) = &artifacts.json_path {
4623 p.clone()
4624 } else {
4625 let html = ErrorTemplate {
4626 message: "JSON result was not saved for this run.".to_string(),
4627 last_report_url: Some("/view-reports".to_string()),
4628 last_report_label: Some("View Reports".to_string()),
4629 run_id: Some(run_id.clone()),
4630 error_code: Some(404),
4631 csp_nonce: csp_nonce.clone(),
4632 version: env!("CARGO_PKG_VERSION"),
4633 }
4634 .render()
4635 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4636 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4637 };
4638
4639 let Ok(run) = read_json(&json_path) else {
4640 let folder_hint = json_path
4641 .parent()
4642 .map(|p| p.display().to_string())
4643 .unwrap_or_default();
4644 let redirect_url = format!("/runs/result/{run_id}");
4645 return missing_scan_relocate_response(
4646 &format!(
4647 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
4648 deleted. Browse to the folder containing your scan output to reconnect it.",
4649 json_path.display()
4650 ),
4651 &run_id,
4652 &folder_hint,
4653 &redirect_url,
4654 state.server_mode,
4655 &csp_nonce,
4656 );
4657 };
4658
4659 let confluence_configured = {
4660 let store = state.confluence.lock().await;
4661 store.is_configured()
4662 };
4663
4664 render_result_page(
4665 &run,
4666 &artifacts,
4667 &run_id,
4668 &csp_nonce,
4669 confluence_configured,
4670 state.server_mode,
4671 )
4672}
4673
4674#[allow(clippy::too_many_lines)]
4675#[allow(clippy::similar_names)] fn render_result_page(
4677 run: &AnalysisRun,
4678 artifacts: &RunArtifacts,
4679 run_id: &str,
4680 csp_nonce: &str,
4681 confluence_configured: bool,
4682 server_mode: bool,
4683) -> Response {
4684 let ctx = &artifacts.result_context;
4685 let prev_entry = &ctx.prev_entry;
4686 let prev_scan_count = ctx.prev_scan_count;
4687 let project_path = &ctx.project_path;
4688
4689 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4690 prev.json_path
4691 .as_ref()
4692 .and_then(|p| read_json(p).ok())
4693 .map(|prev_run| compute_delta(&prev_run, run))
4694 });
4695
4696 let files_analyzed = run.per_file_records.len() as u64;
4697 let files_skipped = run.skipped_file_records.len() as u64;
4698 let physical_lines = run
4699 .totals_by_language
4700 .iter()
4701 .map(|r| r.total_physical_lines)
4702 .sum::<u64>();
4703 let code_lines = run
4704 .totals_by_language
4705 .iter()
4706 .map(|r| r.code_lines)
4707 .sum::<u64>();
4708 let comment_lines = run
4709 .totals_by_language
4710 .iter()
4711 .map(|r| r.comment_lines)
4712 .sum::<u64>();
4713 let blank_lines = run
4714 .totals_by_language
4715 .iter()
4716 .map(|r| r.blank_lines)
4717 .sum::<u64>();
4718 let mixed_lines = run
4719 .totals_by_language
4720 .iter()
4721 .map(|r| r.mixed_lines_separate)
4722 .sum::<u64>();
4723 let functions = run
4724 .totals_by_language
4725 .iter()
4726 .map(|r| r.functions)
4727 .sum::<u64>();
4728 let classes = run
4729 .totals_by_language
4730 .iter()
4731 .map(|r| r.classes)
4732 .sum::<u64>();
4733 let variables = run
4734 .totals_by_language
4735 .iter()
4736 .map(|r| r.variables)
4737 .sum::<u64>();
4738 let imports = run
4739 .totals_by_language
4740 .iter()
4741 .map(|r| r.imports)
4742 .sum::<u64>();
4743
4744 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4745 let prev_fa = prev_sum.map(|s| s.files_analyzed);
4746 let prev_fs = prev_sum.map(|s| s.files_skipped);
4747 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4748 let prev_cl = prev_sum.map(|s| s.code_lines);
4749 let prev_cml = prev_sum.map(|s| s.comment_lines);
4750 let prev_bl = prev_sum.map(|s| s.blank_lines);
4751 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4752 let prev_fa_str = fmt_prev(prev_fa);
4753 let prev_fs_str = fmt_prev(prev_fs);
4754 let prev_pl_str = fmt_prev(prev_pl);
4755 let prev_cl_str = fmt_prev(prev_cl);
4756 let prev_cml_str = fmt_prev(prev_cml);
4757 let prev_bl_str = fmt_prev(prev_bl);
4758 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4759 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4760 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4761 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4762 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4763 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4764 let delta_fa_class = delta_fa_class.to_string();
4765 let delta_fs_class = delta_fs_class.to_string();
4766 let delta_pl_class = delta_pl_class.to_string();
4767 let delta_cl_class = delta_cl_class.to_string();
4768 let delta_cml_class = delta_cml_class.to_string();
4769 let delta_bl_class = delta_bl_class.to_string();
4770
4771 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4772 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4773 let (delta_lines_net_str, delta_lines_net_class) =
4774 match (delta_lines_added, delta_lines_removed) {
4775 (Some(a), Some(r)) => {
4776 let net = a - r;
4777 (fmt_delta(net), delta_class(net).to_string())
4778 }
4779 _ => ("—".to_string(), "na".to_string()),
4780 };
4781
4782 let run_dir = artifacts.output_dir.clone();
4783 let git_branch = run.git_branch.clone();
4784 let git_commit = run.git_commit_short.clone();
4785 let git_commit_long = run.git_commit_long.clone();
4786 let git_author = run.git_commit_author.clone();
4787 let git_commit_url = run
4788 .git_remote_url
4789 .as_deref()
4790 .zip(run.git_commit_long.as_deref())
4791 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4792 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4793 format!(
4794 "{} / {}",
4795 run.environment.initiator_username, run.environment.initiator_hostname
4796 )
4797 });
4798 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
4799 let os_display = format!(
4800 "{} / {}",
4801 run.environment.operating_system, run.environment.architecture
4802 );
4803 let test_count = run.summary_totals.test_count;
4804
4805 let template = ResultTemplate {
4806 version: env!("CARGO_PKG_VERSION"),
4807 report_title: run.effective_configuration.reporting.report_title.clone(),
4808 project_path: project_path.clone(),
4809 output_dir: display_path(&artifacts.output_dir),
4810 run_id: run_id.to_owned(),
4811 run_id_short: run_id
4812 .split('-')
4813 .next_back()
4814 .unwrap_or(run_id)
4815 .chars()
4816 .take(7)
4817 .collect(),
4818 files_analyzed,
4819 files_skipped,
4820 physical_lines,
4821 code_lines,
4822 comment_lines,
4823 blank_lines,
4824 mixed_lines,
4825 functions,
4826 classes,
4827 variables,
4828 imports,
4829 html_url: artifacts
4830 .html_path
4831 .as_ref()
4832 .map(|_| format!("/runs/html/{run_id}")),
4833 pdf_url: artifacts
4834 .pdf_path
4835 .as_ref()
4836 .map(|_| format!("/runs/pdf/{run_id}")),
4837 json_url: artifacts
4838 .json_path
4839 .as_ref()
4840 .map(|_| format!("/runs/json/{run_id}")),
4841 html_download_url: artifacts
4842 .html_path
4843 .as_ref()
4844 .map(|_| format!("/runs/html/{run_id}?download=1")),
4845 pdf_download_url: artifacts
4846 .pdf_path
4847 .as_ref()
4848 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4849 json_download_url: artifacts
4850 .json_path
4851 .as_ref()
4852 .map(|_| format!("/runs/json/{run_id}?download=1")),
4853 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4854 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4855 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4856 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4857 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4858 prev_fa_str,
4859 prev_fs_str,
4860 prev_pl_str,
4861 prev_cl_str,
4862 prev_cml_str,
4863 prev_bl_str,
4864 delta_fa_str,
4865 delta_fa_class,
4866 delta_fs_str,
4867 delta_fs_class,
4868 delta_pl_str,
4869 delta_pl_class,
4870 delta_cl_str,
4871 delta_cl_class,
4872 delta_cml_str,
4873 delta_cml_class,
4874 delta_bl_str,
4875 delta_bl_class,
4876 delta_lines_added,
4877 delta_lines_removed,
4878 delta_lines_net_str,
4879 delta_lines_net_class,
4880 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4881 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4882 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4883 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4884 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4885 d.file_deltas
4886 .iter()
4887 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4888 .map(|f| {
4889 #[allow(clippy::cast_sign_loss)]
4890 let n = f.current_code as u64;
4891 n
4892 })
4893 .sum()
4894 }),
4895 git_branch,
4896 git_commit,
4897 git_commit_long,
4898 git_author,
4899 git_commit_url,
4900 scan_performed_by,
4901 scan_time_display,
4902 os_display,
4903 test_count,
4904 current_scan_number: prev_scan_count + 1,
4905 prev_scan_count,
4906 submodule_rows: run
4907 .submodule_summaries
4908 .iter()
4909 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
4910 .collect(),
4911 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4912 scan_config_url: format!("/runs/scan-config/{run_id}"),
4913 lang_chart_json: {
4914 let mut langs: Vec<&sloc_core::LanguageSummary> =
4915 run.totals_by_language.iter().collect();
4916 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
4917 let entries: Vec<String> = langs
4918 .into_iter()
4919 .take(12)
4920 .map(|l| {
4921 let name = l
4922 .language
4923 .display_name()
4924 .replace('\\', "\\\\")
4925 .replace('"', "\\\"");
4926 format!(
4927 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4928 name,
4929 l.code_lines,
4930 l.comment_lines,
4931 l.blank_lines,
4932 l.total_physical_lines,
4933 l.functions,
4934 l.classes,
4935 l.variables,
4936 l.imports,
4937 l.files,
4938 )
4939 })
4940 .collect();
4941 format!("[{}]", entries.join(","))
4942 },
4943 scatter_chart_json: {
4944 let entries: Vec<String> = run
4945 .totals_by_language
4946 .iter()
4947 .map(|l| {
4948 let name = l
4949 .language
4950 .display_name()
4951 .replace('\\', "\\\\")
4952 .replace('"', "\\\"");
4953 format!(
4954 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4955 name, l.files, l.code_lines, l.total_physical_lines,
4956 )
4957 })
4958 .collect();
4959 format!("[{}]", entries.join(","))
4960 },
4961 semantic_chart_json: {
4962 let entries: Vec<String> = run
4963 .totals_by_language
4964 .iter()
4965 .filter(|l| {
4966 l.functions > 0
4967 || l.classes > 0
4968 || l.variables > 0
4969 || l.imports > 0
4970 || l.test_count > 0
4971 })
4972 .map(|l| {
4973 let name = l
4974 .language
4975 .display_name()
4976 .replace('\\', "\\\\")
4977 .replace('"', "\\\"");
4978 format!(
4979 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
4980 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
4981 )
4982 })
4983 .collect();
4984 format!("[{}]", entries.join(","))
4985 },
4986 submodule_chart_json: {
4987 let entries: Vec<String> = run
4988 .submodule_summaries
4989 .iter()
4990 .map(|s| {
4991 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4992 format!(
4993 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4994 name,
4995 s.code_lines,
4996 s.comment_lines,
4997 s.blank_lines,
4998 s.total_physical_lines,
4999 s.files_analyzed,
5000 )
5001 })
5002 .collect();
5003 format!("[{}]", entries.join(","))
5004 },
5005 has_submodule_data: !run.submodule_summaries.is_empty(),
5006 has_semantic_data: run
5007 .totals_by_language
5008 .iter()
5009 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5010 csp_nonce: csp_nonce.to_owned(),
5011 confluence_configured,
5012 server_mode,
5013 report_header_footer: run
5014 .effective_configuration
5015 .reporting
5016 .report_header_footer
5017 .clone(),
5018 is_offline: false,
5019 };
5020
5021 Html(
5022 template
5023 .render()
5024 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5025 )
5026 .into_response()
5027}
5028
5029fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5030 let slug: String = report_title
5031 .chars()
5032 .map(|c| {
5033 if c.is_alphanumeric() || c == '-' {
5034 c.to_ascii_lowercase()
5035 } else {
5036 '_'
5037 }
5038 })
5039 .collect::<String>()
5040 .split('_')
5041 .filter(|s| !s.is_empty())
5042 .collect::<Vec<_>>()
5043 .join("_");
5044
5045 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5046
5047 if slug.is_empty() {
5048 format!("report_{short_id}.pdf")
5049 } else {
5050 format!("{slug}_{short_id}.pdf")
5051 }
5052}
5053
5054#[derive(Serialize)]
5055struct PdfStatusResponse {
5056 ready: bool,
5057}
5058
5059async fn pdf_status_handler(
5062 State(state): State<AppState>,
5063 AxumPath(run_id): AxumPath<String>,
5064) -> Response {
5065 let pdf_path = {
5066 let registry = state.artifacts.lock().await;
5067 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5068 };
5069 let pdf_path = if pdf_path.is_some() {
5070 pdf_path
5071 } else {
5072 let reg = state.registry.lock().await;
5073 reg.find_by_run_id(&run_id)
5074 .map(recover_artifacts_from_registry)
5075 .and_then(|a| a.pdf_path)
5076 };
5077 let ready = pdf_path.is_some_and(|p| p.exists());
5078 Json(PdfStatusResponse { ready }).into_response()
5079}
5080
5081async fn download_bundle_handler(
5087 State(state): State<AppState>,
5088 AxumPath(run_id): AxumPath<String>,
5089) -> Response {
5090 let output_dir = {
5092 let cache = state.artifacts.lock().await;
5093 cache.get(&run_id).map(|a| a.output_dir.clone())
5094 };
5095 let output_dir = if let Some(d) = output_dir {
5096 d
5097 } else {
5098 let reg = state.registry.lock().await;
5099 match reg.find_by_run_id(&run_id) {
5100 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5101 None => {
5102 return (
5103 StatusCode::NOT_FOUND,
5104 Json(serde_json::json!({"error": "Run not found"})),
5105 )
5106 .into_response();
5107 }
5108 }
5109 };
5110
5111 if !output_dir.exists() {
5112 return (
5113 StatusCode::NOT_FOUND,
5114 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5115 )
5116 .into_response();
5117 }
5118
5119 let run_id_clone = run_id.clone();
5121 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5122 use flate2::{write::GzEncoder, Compression};
5123 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5124 {
5125 let mut tar = tar::Builder::new(&mut enc);
5126 tar.follow_symlinks(false);
5127 if let Ok(entries) = std::fs::read_dir(&output_dir) {
5130 for entry in entries.filter_map(Result::ok) {
5131 let p = entry.path();
5132 if p.is_file() {
5133 let name = p.file_name().unwrap_or_default().to_string_lossy();
5134 let archive_path = format!("{run_id_clone}/{name}");
5135 tar.append_path_with_name(&p, &archive_path)?;
5136 }
5137 }
5138 }
5139 tar.finish()?;
5140 }
5141 Ok(enc.finish()?)
5142 })
5143 .await;
5144
5145 match archive_result {
5146 Ok(Ok(bytes)) => {
5147 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5148 axum::response::Response::builder()
5149 .status(StatusCode::OK)
5150 .header("Content-Type", "application/gzip")
5151 .header(
5152 "Content-Disposition",
5153 format!("attachment; filename=\"{filename}\""),
5154 )
5155 .header("Content-Length", bytes.len().to_string())
5156 .body(axum::body::Body::from(bytes))
5157 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5158 }
5159 Ok(Err(e)) => (
5160 StatusCode::INTERNAL_SERVER_ERROR,
5161 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5162 )
5163 .into_response(),
5164 Err(e) => (
5165 StatusCode::INTERNAL_SERVER_ERROR,
5166 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5167 )
5168 .into_response(),
5169 }
5170}
5171
5172async fn delete_run_handler(
5177 State(state): State<AppState>,
5178 AxumPath(run_id): AxumPath<String>,
5179) -> Response {
5180 let output_dir = {
5182 let mut cache = state.artifacts.lock().await;
5183 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5184 cache.remove(&run_id);
5185 dir
5186 };
5187 let output_dir = if let Some(d) = output_dir {
5188 d
5189 } else {
5190 let reg = state.registry.lock().await;
5191 reg.find_by_run_id(&run_id)
5192 .map(|e| recover_artifacts_from_registry(e).output_dir)
5193 .unwrap_or_default()
5194 };
5195
5196 {
5198 let mut reg = state.registry.lock().await;
5199 reg.entries.retain(|e| e.run_id != run_id);
5200 let _ = reg.save(&state.registry_path);
5201 }
5202
5203 if output_dir.exists() {
5205 if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
5206 return (
5207 StatusCode::INTERNAL_SERVER_ERROR,
5208 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5209 )
5210 .into_response();
5211 }
5212 }
5213
5214 StatusCode::NO_CONTENT.into_response()
5215}
5216
5217async fn cleanup_runs_handler(
5222 State(state): State<AppState>,
5223 Json(body): Json<serde_json::Value>,
5224) -> Response {
5225 let days = body
5226 .get("older_than_days")
5227 .and_then(serde_json::Value::as_u64)
5228 .unwrap_or(30)
5229 .max(1);
5230
5231 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5232
5233 let expired: Vec<(String, PathBuf)> = {
5235 let reg = state.registry.lock().await;
5236 reg.entries
5237 .iter()
5238 .filter(|e| e.timestamp_utc < cutoff)
5239 .map(|e| {
5240 let arts = recover_artifacts_from_registry(e);
5241 (e.run_id.clone(), arts.output_dir)
5242 })
5243 .collect()
5244 };
5245
5246 let mut deleted = 0usize;
5247 for (run_id, output_dir) in &expired {
5248 state.artifacts.lock().await.remove(run_id);
5250 if output_dir.exists() {
5252 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5253 eprintln!(
5254 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5255 output_dir.display()
5256 );
5257 continue;
5258 }
5259 }
5260 deleted += 1;
5261 }
5262
5263 let expired_ids: std::collections::HashSet<&str> =
5265 expired.iter().map(|(id, _)| id.as_str()).collect();
5266 {
5267 let mut reg = state.registry.lock().await;
5268 reg.entries
5269 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5270 let _ = reg.save(&state.registry_path);
5271 }
5272
5273 Json(serde_json::json!({ "deleted": deleted })).into_response()
5274}
5275
5276fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5279 tokio::spawn(async move {
5280 loop {
5281 let interval_secs = {
5282 let store = state.cleanup_policy.lock().await;
5283 match &store.policy {
5284 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5285 _ => break,
5286 }
5287 };
5288 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5289 let n = run_auto_cleanup(&state).await;
5290 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5291 }
5292 })
5293}
5294
5295fn collect_runs_to_delete(
5296 reg: &ScanRegistry,
5297 max_age_days: Option<u32>,
5298 max_run_count: Option<u32>,
5299) -> std::collections::HashSet<String> {
5300 let mut to_delete = std::collections::HashSet::new();
5301 if let Some(days) = max_age_days {
5302 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5303 for e in ®.entries {
5304 if e.timestamp_utc < cutoff {
5305 to_delete.insert(e.run_id.clone());
5306 }
5307 }
5308 }
5309 if let Some(max_count) = max_run_count {
5310 for e in reg.entries.iter().skip(max_count as usize) {
5312 to_delete.insert(e.run_id.clone());
5313 }
5314 }
5315 to_delete
5316}
5317
5318async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5319 let output_dir = {
5320 let mut cache = state.artifacts.lock().await;
5321 let d = cache.get(run_id).map(|a| a.output_dir.clone());
5322 cache.remove(run_id);
5323 d
5324 };
5325 let output_dir = if let Some(d) = output_dir {
5326 d
5327 } else {
5328 let reg = state.registry.lock().await;
5329 reg.find_by_run_id(run_id)
5330 .map(|e| recover_artifacts_from_registry(e).output_dir)
5331 .unwrap_or_default()
5332 };
5333 if output_dir.exists() {
5334 let _ = tokio::fs::remove_dir_all(&output_dir).await;
5335 }
5336}
5337
5338async fn run_auto_cleanup(state: &AppState) -> u32 {
5342 let (max_age_days, max_run_count) = {
5343 let store = state.cleanup_policy.lock().await;
5344 match &store.policy {
5345 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5346 _ => return 0,
5347 }
5348 };
5349
5350 let to_delete = {
5351 let reg = state.registry.lock().await;
5352 collect_runs_to_delete(®, max_age_days, max_run_count)
5353 };
5354
5355 for run_id in &to_delete {
5356 delete_run_artifacts(state, run_id).await;
5357 }
5358
5359 if !to_delete.is_empty() {
5361 let mut reg = state.registry.lock().await;
5362 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5363 let _ = reg.save(&state.registry_path);
5364 }
5365
5366 let deleted = to_delete.len() as u32;
5367 {
5368 let mut store = state.cleanup_policy.lock().await;
5369 store.last_run_at = Some(chrono::Utc::now());
5370 store.last_run_deleted = Some(deleted);
5371 let _ = store.save(&state.cleanup_policy_path);
5372 }
5373 deleted
5374}
5375
5376async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5380 let store = state.cleanup_policy.lock().await;
5381 Json(serde_json::json!({
5382 "policy": store.policy,
5383 "last_run_at": store.last_run_at,
5384 "last_run_deleted": store.last_run_deleted,
5385 }))
5386 .into_response()
5387}
5388
5389async fn api_save_cleanup_policy(
5391 State(state): State<AppState>,
5392 Json(body): Json<CleanupPolicy>,
5393) -> Response {
5394 {
5396 let mut handle = state.cleanup_task_handle.lock().await;
5397 if let Some(h) = handle.take() {
5398 h.abort();
5399 }
5400 }
5401 {
5402 let mut store = state.cleanup_policy.lock().await;
5403 store.policy = Some(body.clone());
5404 if let Err(e) = store.save(&state.cleanup_policy_path) {
5405 return (
5406 StatusCode::INTERNAL_SERVER_ERROR,
5407 Json(serde_json::json!({"error": e.to_string()})),
5408 )
5409 .into_response();
5410 }
5411 }
5412 if body.enabled {
5413 let handle = spawn_cleanup_policy_task(state.clone());
5414 *state.cleanup_task_handle.lock().await = Some(handle);
5415 }
5416 StatusCode::NO_CONTENT.into_response()
5417}
5418
5419async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5421 let deleted = run_auto_cleanup(&state).await;
5422 Json(serde_json::json!({ "deleted": deleted })).into_response()
5423}
5424
5425async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5427 {
5428 let mut handle = state.cleanup_task_handle.lock().await;
5429 if let Some(h) = handle.take() {
5430 h.abort();
5431 }
5432 }
5433 {
5434 let mut store = state.cleanup_policy.lock().await;
5435 store.policy = None;
5436 let _ = store.save(&state.cleanup_policy_path);
5437 }
5438 StatusCode::NO_CONTENT.into_response()
5439}
5440
5441fn swap_inline_chart_js_for_static(html: String) -> String {
5447 let Some(head_end) = html.find("</head>") else {
5448 return html;
5449 };
5450 let Some(script_start) = html[..head_end].rfind("<script") else {
5451 return html;
5452 };
5453 let Some(close_offset) = html[script_start..].find("</script>") else {
5454 return html;
5455 };
5456 let block_end = script_start + close_offset + "</script>".len();
5457 format!(
5458 "{}<script src=\"/static/chart-report.js\"></script>{}",
5459 &html[..script_start],
5460 &html[block_end..]
5461 )
5462}
5463
5464fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5466 let Some(start) = html.find("nonce=\"") else {
5468 return html
5472 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5473 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5474 };
5475 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
5477 return html.to_owned();
5478 };
5479 let old_nonce = &html[value_start..value_start + end_offset];
5480 html.replace(
5481 &format!("nonce=\"{old_nonce}\""),
5482 &format!("nonce=\"{new_nonce}\""),
5483 )
5484}
5485
5486fn serve_html_artifact(
5487 path: &Path,
5488 wants_download: bool,
5489 csp_nonce: &str,
5490 run_id: &str,
5491 server_mode: bool,
5492) -> Response {
5493 match fs::read_to_string(path) {
5494 Ok(raw) => {
5495 let content = patch_html_nonce(&raw, csp_nonce);
5497 if wants_download {
5498 (
5500 [
5501 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5502 (
5503 header::CONTENT_DISPOSITION,
5504 "attachment; filename=report.html",
5505 ),
5506 ],
5507 content,
5508 )
5509 .into_response()
5510 } else {
5511 Html(swap_inline_chart_js_for_static(content)).into_response()
5514 }
5515 }
5516 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5517 let filename = path.file_name().map_or_else(
5518 || "report.html".to_string(),
5519 |n| n.to_string_lossy().into_owned(),
5520 );
5521 let html = LocateFileTemplate {
5522 run_id: run_id.to_owned(),
5523 artifact_type: "html".to_string(),
5524 expected_filename: filename,
5525 server_mode,
5526 csp_nonce: csp_nonce.to_owned(),
5527 version: env!("CARGO_PKG_VERSION"),
5528 }
5529 .render()
5530 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5531 (StatusCode::NOT_FOUND, Html(html)).into_response()
5532 }
5533 Err(err) => {
5534 let filename = path.file_name().map_or_else(
5535 || "report.html".to_string(),
5536 |n| n.to_string_lossy().into_owned(),
5537 );
5538 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5539 let html = ErrorTemplate {
5540 message: msg,
5541 last_report_url: Some("/view-reports".to_string()),
5542 last_report_label: Some("View Reports".to_string()),
5543 run_id: None,
5544 error_code: Some(404),
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 }
5553}
5554
5555fn serve_pdf_artifact(
5557 path: &Path,
5558 report_title: &str,
5559 run_id: &str,
5560 wants_download: bool,
5561 csp_nonce: &str,
5562) -> Response {
5563 match fs::read(path) {
5564 Ok(bytes) => {
5565 let filename = build_pdf_filename(report_title, run_id);
5566 let disposition = if wants_download {
5567 format!("attachment; filename=\"{filename}\"")
5568 } else {
5569 format!("inline; filename=\"{filename}\"")
5570 };
5571 (
5572 [
5573 (header::CONTENT_TYPE, "application/pdf".to_string()),
5574 (header::CONTENT_DISPOSITION, disposition),
5575 ],
5576 bytes,
5577 )
5578 .into_response()
5579 }
5580 Err(err) => {
5581 let filename = path.file_name().map_or_else(
5582 || "report.pdf".to_string(),
5583 |n| n.to_string_lossy().into_owned(),
5584 );
5585 let msg = format!(
5586 "PDF report '{filename}' could not be read.\n\n\
5587 Error: {err}\n\n\
5588 If you moved or renamed the output folder, the stored path is now stale. \
5589 Use 'Open PDF folder' from the results page to browse the output directory."
5590 );
5591 let html = ErrorTemplate {
5592 message: msg,
5593 last_report_url: Some("/view-reports".to_string()),
5594 last_report_label: Some("View Reports".to_string()),
5595 run_id: Some(run_id.to_owned()),
5596 error_code: Some(404),
5597 csp_nonce: csp_nonce.to_owned(),
5598 version: env!("CARGO_PKG_VERSION"),
5599 }
5600 .render()
5601 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5602 (StatusCode::NOT_FOUND, Html(html)).into_response()
5603 }
5604 }
5605}
5606
5607fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5609 match fs::read(path) {
5610 Ok(bytes) => {
5611 if wants_download {
5612 (
5613 [
5614 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5615 (
5616 header::CONTENT_DISPOSITION,
5617 "attachment; filename=result.json",
5618 ),
5619 ],
5620 bytes,
5621 )
5622 .into_response()
5623 } else {
5624 (
5625 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5626 bytes,
5627 )
5628 .into_response()
5629 }
5630 }
5631 Err(err) => {
5632 let filename = path.file_name().map_or_else(
5633 || "result.json".to_string(),
5634 |n| n.to_string_lossy().into_owned(),
5635 );
5636 let msg = format!(
5637 "JSON result '{filename}' could not be read.\n\n\
5638 Error: {err}\n\n\
5639 If you moved or renamed the output folder, the stored path is now stale. \
5640 Use 'Open JSON folder' from the results page to browse the output directory."
5641 );
5642 let html = ErrorTemplate {
5643 message: msg,
5644 last_report_url: Some("/view-reports".to_string()),
5645 last_report_label: Some("View Reports".to_string()),
5646 run_id: None,
5647 error_code: Some(404),
5648 csp_nonce: csp_nonce.to_owned(),
5649 version: env!("CARGO_PKG_VERSION"),
5650 }
5651 .render()
5652 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5653 (StatusCode::NOT_FOUND, Html(html)).into_response()
5654 }
5655 }
5656}
5657
5658fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5660 let output_dir = entry
5663 .html_path
5664 .as_ref()
5665 .or(entry.json_path.as_ref())
5666 .or(entry.pdf_path.as_ref())
5667 .or(entry.csv_path.as_ref())
5668 .or(entry.xlsx_path.as_ref())
5669 .and_then(|p| {
5670 let parent = p.parent()?;
5671 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
5672 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
5674 parent.parent().map(PathBuf::from)
5675 } else {
5676 Some(parent.to_path_buf())
5677 }
5678 })
5679 .unwrap_or_default();
5680 let pdf_path = entry.pdf_path.clone().or_else(|| {
5683 let candidate = output_dir.join("report.pdf");
5684 candidate.exists().then_some(candidate)
5685 });
5686 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
5690 for dir in &[output_dir.join("excel"), output_dir.clone()] {
5692 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
5693 entries
5694 .filter_map(std::result::Result::ok)
5695 .find(|e| {
5696 let n = e.file_name();
5697 let n = n.to_string_lossy();
5698 n.starts_with("report_") && n.ends_with(ext)
5699 })
5700 .map(|e| e.path())
5701 }) {
5702 return Some(p);
5703 }
5704 }
5705 None
5706 };
5707
5708 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
5709 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
5710 RunArtifacts {
5711 output_dir: output_dir.clone(),
5712 html_path: entry.html_path.clone(),
5713 pdf_path,
5714 json_path: entry.json_path.clone(),
5715 csv_path,
5716 xlsx_path,
5717 scan_config_path: find_scan_config_in_dir(&output_dir),
5718 report_title: entry.project_label.clone(),
5719 result_context: RunResultContext::default(),
5720 }
5721}
5722
5723#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
5725 state: &AppState,
5726 run_id: &str,
5727 csp_nonce: &str,
5728) -> Result<RunArtifacts, Response> {
5729 let cached = state.artifacts.lock().await.get(run_id).cloned();
5730 if let Some(a) = cached {
5731 return Ok(a);
5732 }
5733 let reg = state.registry.lock().await;
5734 if let Some(entry) = reg.find_by_run_id(run_id) {
5735 return Ok(recover_artifacts_from_registry(entry));
5736 }
5737 drop(reg);
5738 let short_id = &run_id[..run_id.len().min(8)];
5739 let hint = if matches!(
5740 run_id,
5741 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
5742 ) {
5743 format!(
5744 " The URL format appears to be reversed — \
5745 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
5746 Use the View Reports page to navigate to your scan."
5747 )
5748 } else {
5749 " The report may have been deleted or the report directory moved. \
5750 Use View Reports to browse your scan history."
5751 .to_string()
5752 };
5753 let error_html = ErrorTemplate {
5754 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
5755 last_report_url: Some("/view-reports".to_string()),
5756 last_report_label: Some("View Reports".to_string()),
5757 run_id: None,
5758 error_code: Some(404),
5759 csp_nonce: csp_nonce.to_owned(),
5760 version: env!("CARGO_PKG_VERSION"),
5761 }
5762 .render()
5763 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5764 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
5765}
5766
5767async fn resolve_or_queue_pdf(
5772 state: &AppState,
5773 pdf_path: Option<PathBuf>,
5774 json_path: Option<PathBuf>,
5775 output_dir: PathBuf,
5776 run_id: &str,
5777 report_title: &str,
5778 csp_nonce: &str,
5779) -> Result<PathBuf, Response> {
5780 if let Some(p) = pdf_path {
5781 return Ok(p);
5782 }
5783 let Some(json_src) = json_path.filter(|p| p.exists()) else {
5784 let msg = "PDF report was not generated for this run. \
5785 Re-run the analysis with PDF output enabled."
5786 .to_string();
5787 let html = ErrorTemplate {
5788 message: msg,
5789 last_report_url: Some(format!("/runs/html/{run_id}")),
5790 last_report_label: Some("View HTML Report".to_string()),
5791 run_id: Some(run_id.to_string()),
5792 error_code: Some(404),
5793 csp_nonce: csp_nonce.to_string(),
5794 version: env!("CARGO_PKG_VERSION"),
5795 }
5796 .render()
5797 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
5798 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5799 };
5800 let pdf_filename = build_pdf_filename(report_title, run_id);
5801 let pdf_dest = output_dir.join(&pdf_filename);
5802 if !pdf_dest.exists() {
5803 {
5805 let mut map = state.artifacts.lock().await;
5806 if let Some(entry) = map.get_mut(run_id) {
5807 entry.pdf_path = Some(pdf_dest.clone());
5808 }
5809 }
5810 {
5811 let mut reg = state.registry.lock().await;
5812 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
5813 e.pdf_path = Some(pdf_dest.clone());
5814 }
5815 let _ = reg.save(&state.registry_path);
5816 }
5817 spawn_native_pdf_background(
5818 json_src,
5819 pdf_dest.clone(),
5820 run_id.to_string(),
5821 state.artifacts.clone(),
5822 );
5823 }
5824 Ok(pdf_dest)
5825}
5826
5827fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
5829 let html = format!(
5830 "<!doctype html><html lang=\"en\"><head>\
5831 <meta charset=utf-8>\
5832 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
5833 <meta http-equiv=\"refresh\" content=\"5\">\
5834 <title>OxideSLOC | Generating PDF\u{2026}</title>\
5835 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
5836 <style nonce=\"{csp_nonce}\">\
5837 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
5838 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
5839 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
5840 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
5841 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
5842 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
5843 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
5844 background:var(--bg);color:var(--text);}}\
5845 .top-nav{{position:sticky;top:0;z-index:30;\
5846 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
5847 border-bottom:1px solid rgba(255,255,255,0.12);\
5848 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
5849 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
5850 min-height:56px;display:flex;align-items:center;gap:14px;}}\
5851 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
5852 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
5853 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
5854 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
5855 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
5856 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
5857 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
5858 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
5859 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
5860 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
5861 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
5862 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
5863 justify-content:center;min-height:38px;border-radius:999px;\
5864 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
5865 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
5866 .theme-toggle .icon-sun{{display:none;}}\
5867 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
5868 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
5869 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
5870 display:flex;align-items:center;justify-content:center;\
5871 min-height:calc(100vh - 56px);}}\
5872 .panel{{background:var(--surface);border:1px solid var(--line);\
5873 border-radius:var(--radius);box-shadow:var(--shadow);\
5874 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
5875 .spin-ring{{width:56px;height:56px;border-radius:50%;\
5876 border:5px solid var(--line);border-top-color:var(--oxide-2);\
5877 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
5878 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
5879 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
5880 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
5881 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
5882 min-height:42px;padding:0 20px;border-radius:14px;\
5883 border:1px solid var(--line-strong);text-decoration:none;\
5884 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
5885 .back-link:hover{{background:var(--line);}}\
5886 </style></head>\
5887 <body>\
5888 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
5889 <a class=\"brand\" href=\"/\">\
5890 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
5891 <div class=\"brand-copy\">\
5892 <div class=\"brand-title\">OxideSLOC</div>\
5893 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5894 </div>\
5895 </a>\
5896 <div class=\"nav-right\">\
5897 <a class=\"nav-pill\" href=\"/\">Home</a>\
5898 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
5899 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
5900 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5901 <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>\
5902 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5903 <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>\
5904 </button>\
5905 </div>\
5906 </div></div>\
5907 <div class=\"page\"><div class=\"panel\">\
5908 <div class=\"spin-ring\"></div>\
5909 <h1>Generating PDF\u{2026}</h1>\
5910 <p>The PDF is being generated from the scan results.<br>\
5911 This page refreshes automatically \u{2014} usually a few seconds.</p>\
5912 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5913 </div></div>\
5914 <script nonce=\"{csp_nonce}\">\
5915 (function(){{\
5916 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5917 if(s===\"dark\")b.classList.add(\"dark-theme\");\
5918 var t=document.getElementById(\"theme-toggle\");\
5919 if(t)t.addEventListener(\"click\",function(){{\
5920 var d=b.classList.toggle(\"dark-theme\");\
5921 localStorage.setItem(k,d?\"dark\":\"light\");\
5922 }});\
5923 }})();\
5924 </script>\
5925 </body></html>"
5926 );
5927 Html(html).into_response()
5928}
5929
5930fn render_error_artifact_html(
5932 message: String,
5933 last_report_url: Option<String>,
5934 last_report_label: Option<String>,
5935 run_id: Option<String>,
5936 error_code: Option<u16>,
5937 csp_nonce: &str,
5938) -> String {
5939 ErrorTemplate {
5940 message,
5941 last_report_url,
5942 last_report_label,
5943 run_id,
5944 error_code,
5945 csp_nonce: csp_nonce.to_owned(),
5946 version: env!("CARGO_PKG_VERSION"),
5947 }
5948 .render()
5949 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
5950}
5951
5952fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
5954 fs::read(path).map_or_else(
5955 |_| StatusCode::NOT_FOUND.into_response(),
5956 |bytes| {
5957 let filename = path.file_name().map_or_else(
5958 || fallback_filename.to_string(),
5959 |n| n.to_string_lossy().into_owned(),
5960 );
5961 (
5962 [
5963 (header::CONTENT_TYPE, content_type.to_string()),
5964 (
5965 header::CONTENT_DISPOSITION,
5966 format!("attachment; filename=\"{filename}\""),
5967 ),
5968 ],
5969 bytes,
5970 )
5971 .into_response()
5972 },
5973 )
5974}
5975
5976fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
5977 let Some(path) = csv_path else {
5978 let html = render_error_artifact_html(
5979 "CSV report was not generated for this run, or was not recorded in \
5980 the scan registry."
5981 .to_string(),
5982 Some(format!("/runs/html/{run_id}")),
5983 Some("View HTML Report".to_string()),
5984 Some(run_id.to_string()),
5985 Some(404),
5986 csp_nonce,
5987 );
5988 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5989 };
5990 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
5991}
5992
5993fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
5994 let Some(path) = xlsx_path else {
5995 let html = render_error_artifact_html(
5996 "Excel report was not generated for this run, or was not recorded in \
5997 the scan registry."
5998 .to_string(),
5999 Some(format!("/runs/html/{run_id}")),
6000 Some("View HTML Report".to_string()),
6001 Some(run_id.to_string()),
6002 Some(404),
6003 csp_nonce,
6004 );
6005 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6006 };
6007 serve_binary_download(
6008 &path,
6009 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6010 "report.xlsx",
6011 )
6012}
6013
6014fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6015 let path = artifact_set
6016 .scan_config_path
6017 .as_deref()
6018 .map(std::path::Path::to_path_buf)
6019 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6020 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6021 fs::read(&path).map_or_else(
6022 |_| StatusCode::NOT_FOUND.into_response(),
6023 |bytes| {
6024 (
6025 [
6026 (
6027 header::CONTENT_TYPE,
6028 "application/json; charset=utf-8".to_string(),
6029 ),
6030 (
6031 header::CONTENT_DISPOSITION,
6032 "attachment; filename=\"scan-config.json\"".to_string(),
6033 ),
6034 ],
6035 bytes,
6036 )
6037 .into_response()
6038 },
6039 )
6040}
6041
6042fn serve_submodule_arm(
6043 artifact: &str,
6044 artifact_set: RunArtifacts,
6045 wants_download: bool,
6046 csp_nonce: &str,
6047 run_id: &str,
6048 server_mode: bool,
6049) -> Response {
6050 if artifact.len() > 128
6051 || !artifact
6052 .chars()
6053 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6054 {
6055 return StatusCode::BAD_REQUEST.into_response();
6056 }
6057 let filename = format!("{artifact}.html");
6058 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6060 let path = if new_layout.exists() {
6061 new_layout
6062 } else {
6063 artifact_set.output_dir.join(&filename)
6064 };
6065 if !path.exists() {
6066 let html = render_error_artifact_html(
6067 format!(
6068 "Sub-report '{artifact}' was not found in the run directory.\n\
6069 Re-run the analysis with 'Detect and separate git submodules' \
6070 and HTML output enabled."
6071 ),
6072 Some("/view-reports".to_string()),
6073 Some("View Reports".to_string()),
6074 Some(run_id.to_string()),
6075 Some(404),
6076 csp_nonce,
6077 );
6078 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6079 }
6080 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6081}
6082
6083async fn serve_pdf_arm(
6084 state: &AppState,
6085 artifact_set: RunArtifacts,
6086 wants_download: bool,
6087 run_id: &str,
6088 csp_nonce: &str,
6089) -> Response {
6090 let report_title = artifact_set.report_title.clone();
6091 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6092 let stale_html_name = artifact_set
6093 .html_path
6094 .as_deref()
6095 .and_then(|p| p.file_name())
6096 .map(|n| n.to_string_lossy().into_owned());
6097 let path = match resolve_or_queue_pdf(
6098 state,
6099 artifact_set.pdf_path,
6100 artifact_set.json_path.clone(),
6101 artifact_set.output_dir.clone(),
6102 run_id,
6103 &report_title,
6104 csp_nonce,
6105 )
6106 .await
6107 {
6108 Ok(p) => p,
6109 Err(r) => return r,
6110 };
6111 if !path.exists() {
6112 if had_pdf_in_registry {
6116 if let Some(expected_filename) = stale_html_name {
6117 let html = LocateFileTemplate {
6118 run_id: run_id.to_string(),
6119 artifact_type: "pdf".to_string(),
6120 expected_filename,
6121 server_mode: state.server_mode,
6122 csp_nonce: csp_nonce.to_string(),
6123 version: env!("CARGO_PKG_VERSION"),
6124 }
6125 .render()
6126 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6127 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6128 }
6129 }
6130 return pdf_generating_response(run_id, csp_nonce);
6131 }
6132 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6133}
6134
6135async fn artifact_handler(
6136 State(state): State<AppState>,
6137 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6138 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6139 Query(query): Query<ArtifactQuery>,
6140) -> Response {
6141 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6142 Ok(a) => a,
6143 Err(r) => return r,
6144 };
6145
6146 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6147
6148 match artifact.as_str() {
6149 "html" => {
6150 let Some(path) = artifact_set.html_path else {
6151 return StatusCode::NOT_FOUND.into_response();
6152 };
6153 serve_html_artifact(
6154 &path,
6155 wants_download,
6156 &csp_nonce,
6157 &run_id,
6158 state.server_mode,
6159 )
6160 }
6161 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6162 "json" => {
6163 let Some(path) = artifact_set.json_path else {
6164 let html = render_error_artifact_html(
6165 "JSON result was not generated for this run, or was not recorded in \
6166 the scan registry. Re-run the analysis with JSON output enabled."
6167 .to_string(),
6168 Some("/view-reports".to_string()),
6169 Some("View Reports".to_string()),
6170 Some(run_id.clone()),
6171 Some(404),
6172 &csp_nonce,
6173 );
6174 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6175 };
6176 serve_json_artifact(&path, wants_download, &csp_nonce)
6177 }
6178 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6179 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6180 "scan-config" => serve_scan_config_arm(&artifact_set),
6181 _ if artifact.starts_with("sub_") => serve_submodule_arm(
6182 &artifact,
6183 artifact_set,
6184 wants_download,
6185 &csp_nonce,
6186 &run_id,
6187 state.server_mode,
6188 ),
6189 _ => StatusCode::NOT_FOUND.into_response(),
6190 }
6191}
6192
6193struct SubmoduleLinkRow {
6196 name: String,
6197 url: String,
6198}
6199
6200struct HistoryEntryRow {
6201 run_id: String,
6202 run_id_short: String,
6203 timestamp: String,
6204 timestamp_utc_ms: i64,
6205 project_label: String,
6206 project_path: String,
6207 files_analyzed: u64,
6208 files_skipped: u64,
6209 code_lines: u64,
6210 comment_lines: u64,
6211 blank_lines: u64,
6212 git_branch: String,
6213 git_commit: String,
6214 has_html: bool,
6215 has_json: bool,
6216 has_pdf: bool,
6217 submodule_links: Vec<SubmoduleLinkRow>,
6218 submodule_names_csv: String,
6220}
6221
6222fn nth_weekday_of_month(
6224 year: i32,
6225 month: u32,
6226 weekday: chrono::Weekday,
6227 n: u32,
6228) -> chrono::NaiveDate {
6229 use chrono::Datelike;
6230 let mut count = 0u32;
6231 let mut day = 1u32;
6232 loop {
6233 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6234 if d.weekday() == weekday {
6235 count += 1;
6236 if count == n {
6237 return d;
6238 }
6239 }
6240 day += 1;
6241 }
6242}
6243
6244fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6248 use chrono::{Datelike, TimeZone};
6249 let year = dt.year();
6250 let dst_start = chrono::Utc.from_utc_datetime(
6251 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6252 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6253 );
6254 let dst_end = chrono::Utc.from_utc_datetime(
6255 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6256 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6257 );
6258 dt >= dst_start && dt < dst_end
6259}
6260
6261fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6262 if is_pacific_dst(dt) {
6263 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6264 .format("%Y-%m-%d %H:%M PDT")
6265 .to_string()
6266 } else {
6267 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6268 .format("%Y-%m-%d %H:%M PST")
6269 .to_string()
6270 }
6271}
6272
6273fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6275 let (offset, tz) = if is_pacific_dst(dt) {
6276 (
6277 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6278 "PDT",
6279 )
6280 } else {
6281 (
6282 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6283 "PST",
6284 )
6285 };
6286 format!(
6287 "{} {tz}",
6288 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6289 )
6290}
6291
6292fn fmt_git_date(iso: &str) -> Option<String> {
6293 chrono::DateTime::parse_from_rfc3339(iso)
6294 .ok()
6295 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6296}
6297
6298fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6299 reg.entries
6300 .iter()
6301 .map(|e| {
6302 let submodule_links = {
6303 let mut links: Vec<SubmoduleLinkRow> = vec![];
6304 let sub_dir = e
6305 .html_path
6306 .as_ref()
6307 .and_then(|p| p.parent())
6308 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6309 if let Some(dir) = sub_dir {
6310 if let Ok(rd) = std::fs::read_dir(dir) {
6311 for entry_res in rd.flatten() {
6312 let fname = entry_res.file_name();
6313 let fname_str = fname.to_string_lossy();
6314 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6315 let stem = &fname_str[..fname_str.len() - 5];
6316 let display = stem[4..].replace('-', " ");
6317 links.push(SubmoduleLinkRow {
6318 name: display,
6319 url: format!("/runs/{stem}/{}", e.run_id),
6320 });
6321 }
6322 }
6323 }
6324 }
6325 links.sort_by(|a, b| a.name.cmp(&b.name));
6326 links
6327 };
6328 let submodule_names_csv = submodule_links
6329 .iter()
6330 .map(|l| l.name.as_str())
6331 .collect::<Vec<_>>()
6332 .join(",");
6333 HistoryEntryRow {
6334 run_id: e.run_id.clone(),
6335 run_id_short: e
6336 .run_id
6337 .split('-')
6338 .next_back()
6339 .unwrap_or(&e.run_id)
6340 .chars()
6341 .take(7)
6342 .collect(),
6343 timestamp: fmt_la_time(e.timestamp_utc),
6344 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6345 project_label: e.project_label.clone(),
6346 project_path: e
6347 .input_roots
6348 .first()
6349 .map(|s| sanitize_path_str(s))
6350 .unwrap_or_default(),
6351 files_analyzed: e.summary.files_analyzed,
6352 files_skipped: e.summary.files_skipped,
6353 code_lines: e.summary.code_lines,
6354 comment_lines: e.summary.comment_lines,
6355 blank_lines: e.summary.blank_lines,
6356 git_branch: e.git_branch.clone().unwrap_or_default(),
6357 git_commit: e.git_commit.clone().unwrap_or_default(),
6358 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6359 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6360 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6361 submodule_links,
6362 submodule_names_csv,
6363 }
6364 })
6365 .collect()
6366}
6367
6368#[derive(Deserialize, Default)]
6369struct HistoryQuery {
6370 linked: Option<String>,
6371 error: Option<String>,
6372}
6373
6374async fn history_handler(
6375 State(state): State<AppState>,
6376 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6377 Query(query): Query<HistoryQuery>,
6378) -> impl IntoResponse {
6379 auto_scan_watched_dirs(&state).await;
6381 let watched_dirs: Vec<String> = {
6382 let wd = state.watched_dirs.lock().await;
6383 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6384 };
6385 let mut entries = {
6386 let reg = state.registry.lock().await;
6387 make_history_rows(®)
6388 };
6389 entries.retain(|e| e.has_html);
6390 let total_scans = entries.len();
6391 let linked_count = query
6392 .linked
6393 .as_deref()
6394 .and_then(|s| s.parse::<usize>().ok())
6395 .unwrap_or(0);
6396 let browse_error = query.error.filter(|s| !s.is_empty());
6397 let template = HistoryTemplate {
6398 version: env!("CARGO_PKG_VERSION"),
6399 entries,
6400 total_scans,
6401 linked_count,
6402 browse_error,
6403 watched_dirs,
6404 csp_nonce,
6405 server_mode: state.server_mode,
6406 };
6407 Html(
6408 template
6409 .render()
6410 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6411 )
6412 .into_response()
6413}
6414
6415async fn compare_select_handler(
6416 State(state): State<AppState>,
6417 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6418) -> impl IntoResponse {
6419 auto_scan_watched_dirs(&state).await;
6420 let watched_dirs: Vec<String> = {
6421 let wd = state.watched_dirs.lock().await;
6422 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6423 };
6424 let mut entries = {
6425 let reg = state.registry.lock().await;
6426 make_history_rows(®)
6427 };
6428 entries.retain(|e| e.has_json);
6429 let total_scans = entries.len();
6430 let template = CompareSelectTemplate {
6431 version: env!("CARGO_PKG_VERSION"),
6432 entries,
6433 total_scans,
6434 watched_dirs,
6435 csp_nonce,
6436 server_mode: state.server_mode,
6437 };
6438 Html(
6439 template
6440 .render()
6441 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6442 )
6443 .into_response()
6444}
6445
6446#[derive(Deserialize, Default)]
6449struct CompareQuery {
6450 a: Option<String>,
6451 b: Option<String>,
6452 sub: Option<String>,
6454 scope: Option<String>,
6456}
6457
6458struct CompareFileDeltaRow {
6459 relative_path: String,
6460 language: String,
6461 status: String,
6462 baseline_code: i64,
6463 current_code: i64,
6464 code_delta_str: String,
6465 code_delta_class: String,
6466 comment_delta_str: String,
6467 comment_delta_class: String,
6468 total_delta_str: String,
6469 total_delta_class: String,
6470}
6471
6472fn recompute_summary_from_records(run: &mut AnalysisRun) {
6475 let files_analyzed = run
6476 .per_file_records
6477 .iter()
6478 .filter(|r| r.language.is_some())
6479 .count() as u64;
6480 let code_lines: u64 = run
6481 .per_file_records
6482 .iter()
6483 .map(|r| r.effective_counts.code_lines)
6484 .sum();
6485 let comment_lines: u64 = run
6486 .per_file_records
6487 .iter()
6488 .map(|r| r.effective_counts.comment_lines)
6489 .sum();
6490 let blank_lines: u64 = run
6491 .per_file_records
6492 .iter()
6493 .map(|r| r.effective_counts.blank_lines)
6494 .sum();
6495 run.summary_totals.files_analyzed = files_analyzed;
6496 run.summary_totals.files_considered = files_analyzed;
6497 run.summary_totals.code_lines = code_lines;
6498 run.summary_totals.comment_lines = comment_lines;
6499 run.summary_totals.blank_lines = blank_lines;
6500 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
6501}
6502
6503fn fmt_delta(n: i64) -> String {
6504 if n > 0 {
6505 format!("+{n}")
6506 } else {
6507 format!("{n}")
6508 }
6509}
6510
6511fn delta_class(n: i64) -> &'static str {
6512 use std::cmp::Ordering;
6513 match n.cmp(&0) {
6514 Ordering::Greater => "pos",
6515 Ordering::Less => "neg",
6516 Ordering::Equal => "zero",
6517 }
6518}
6519
6520#[allow(clippy::cast_precision_loss)]
6522fn fmt_pct(delta: i64, baseline: u64) -> String {
6523 if baseline == 0 {
6524 return "—".to_string();
6525 }
6526 #[allow(clippy::cast_precision_loss)]
6527 let pct = (delta as f64 / baseline as f64) * 100.0;
6528 if pct > 0.049 {
6529 format!("+{pct:.1}%")
6530 } else if pct < -0.049 {
6531 format!("{pct:.1}%")
6532 } else {
6533 "±0%".to_string()
6534 }
6535}
6536
6537fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6539 prev.map_or_else(
6540 || ("—".to_string(), "na"),
6541 |p| {
6542 #[allow(clippy::cast_possible_wrap)]
6543 let d = curr as i64 - p as i64;
6544 (fmt_delta(d), delta_class(d))
6545 },
6546 )
6547}
6548
6549#[allow(clippy::result_large_err)] fn load_scan_for_compare(
6551 json_path: &std::path::Path,
6552 scan_label: &str,
6553 run_id: &str,
6554 server_mode: bool,
6555 compare_url: &str,
6556 csp_nonce: &str,
6557) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6558 match read_json(json_path) {
6559 Ok(r) => Ok(r),
6560 Err(e) => {
6561 if server_mode {
6562 let html = ErrorTemplate {
6563 message: format!(
6564 "Could not load {scan_label} scan data. The scan output folder may have \
6565 been moved, renamed, or deleted. Re-running the analysis will create \
6566 fresh comparison data."
6567 ),
6568 last_report_url: Some("/compare-scans".to_string()),
6569 last_report_label: Some("Compare Scans".to_string()),
6570 run_id: Some(run_id.to_owned()),
6571 error_code: Some(404),
6572 csp_nonce: csp_nonce.to_owned(),
6573 version: env!("CARGO_PKG_VERSION"),
6574 }
6575 .render()
6576 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6577 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6578 }
6579 let msg = format!(
6580 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6581 json_path.display()
6582 );
6583 let folder_hint = json_path
6584 .parent()
6585 .map(|p| p.display().to_string())
6586 .unwrap_or_default();
6587 Err(missing_scan_relocate_response(
6588 &msg,
6589 run_id,
6590 &folder_hint,
6591 compare_url,
6592 false,
6593 csp_nonce,
6594 ))
6595 }
6596 }
6597}
6598
6599struct ChurnStats {
6600 new_scope: bool,
6601 scope_flag: bool,
6602 churn_rate_str: String,
6603 churn_rate_class: String,
6604}
6605
6606fn compute_churn_stats(
6607 baseline_code: u64,
6608 current_code: u64,
6609 lines_added: i64,
6610 lines_removed: i64,
6611) -> ChurnStats {
6612 let new_scope = baseline_code == 0 && current_code > 0;
6613 #[allow(clippy::cast_precision_loss)]
6614 let churn_pct = if baseline_code > 0 {
6615 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
6616 } else {
6617 0.0
6618 };
6619 #[allow(clippy::cast_precision_loss)]
6620 let scope_flag =
6621 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
6622 let churn_rate_str = if new_scope {
6623 "New".to_string()
6624 } else if baseline_code > 0 {
6625 format!("{churn_pct:.1}%")
6626 } else {
6627 "—".to_string()
6628 };
6629 let churn_rate_class = if new_scope || churn_pct > 20.0 {
6630 "high".to_string()
6631 } else if churn_pct > 5.0 {
6632 "med".to_string()
6633 } else {
6634 "low".to_string()
6635 };
6636 ChurnStats {
6637 new_scope,
6638 scope_flag,
6639 churn_rate_str,
6640 churn_rate_class,
6641 }
6642}
6643
6644fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
6648 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
6649 if !has_data {
6650 return String::new();
6651 }
6652 let base_str = s
6653 .baseline_coverage_line_pct
6654 .map(|p| format!("{p:.1}%"))
6655 .unwrap_or_else(|| "\u{2014}".into());
6656 let curr_str = s
6657 .current_coverage_line_pct
6658 .map(|p| format!("{p:.1}%"))
6659 .unwrap_or_else(|| "\u{2014}".into());
6660 let (delta_str, cls) = match s.coverage_line_pct_delta {
6661 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
6662 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
6663 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
6664 None => ("\u{2014}".into(), "zero"),
6665 };
6666 format!(
6667 r#"<div class="delta-card">
6668 <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>
6669 <div class="delta-card-label">Line coverage</div>
6670 <div class="delta-card-from">Before: {base_str}</div>
6671 <div class="delta-card-to">{curr_str}</div>
6672 <span class="delta-card-change {cls}">{delta_str}</span>
6673 </div>"#
6674 )
6675}
6676
6677#[allow(clippy::too_many_lines)]
6678async fn compare_handler(
6679 State(state): State<AppState>,
6680 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6681 Query(query): Query<CompareQuery>,
6682) -> impl IntoResponse {
6683 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
6686 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
6687 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
6688 };
6689
6690 let (maybe_a, maybe_b) = {
6691 let reg = state.registry.lock().await;
6692 (
6693 reg.find_by_run_id(&run_id_a).cloned(),
6694 reg.find_by_run_id(&run_id_b).cloned(),
6695 )
6696 };
6697
6698 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
6699 let html = ErrorTemplate {
6700 message: "One or both run IDs were not found in scan history. \
6701 The runs may have been deleted or the registry may have been reset."
6702 .to_string(),
6703 last_report_url: Some("/compare-scans".to_string()),
6704 last_report_label: Some("Compare Scans".to_string()),
6705 run_id: None,
6706 error_code: None,
6707 csp_nonce: csp_nonce.clone(),
6708 version: env!("CARGO_PKG_VERSION"),
6709 }
6710 .render()
6711 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
6712 return Html(html).into_response();
6713 };
6714
6715 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
6717 (entry_a, entry_b)
6718 } else {
6719 (entry_b, entry_a)
6720 };
6721
6722 if baseline_entry.run_id != run_id_a {
6726 let canonical = format!(
6727 "/compare?a={}&b={}",
6728 baseline_entry.run_id, current_entry.run_id
6729 );
6730 return axum::response::Redirect::to(&canonical).into_response();
6731 }
6732
6733 let (Some(base_json), Some(curr_json)) = (
6734 baseline_entry.json_path.as_ref(),
6735 current_entry.json_path.as_ref(),
6736 ) else {
6737 let html = ErrorTemplate {
6738 message: "Full comparison requires JSON scan data, which was not saved for one or \
6739 both of these runs. JSON is now always saved for new scans — re-run the \
6740 affected projects to enable comparisons."
6741 .to_string(),
6742 last_report_url: Some("/compare-scans".to_string()),
6743 last_report_label: Some("Compare Scans".to_string()),
6744 run_id: None,
6745 error_code: None,
6746 csp_nonce: csp_nonce.clone(),
6747 version: env!("CARGO_PKG_VERSION"),
6748 }
6749 .render()
6750 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
6751 return Html(html).into_response();
6752 };
6753
6754 let compare_url = format!(
6755 "/compare?a={}&b={}",
6756 baseline_entry.run_id, current_entry.run_id
6757 );
6758
6759 let baseline_run = match load_scan_for_compare(
6760 base_json,
6761 "baseline",
6762 &baseline_entry.run_id,
6763 state.server_mode,
6764 &compare_url,
6765 &csp_nonce,
6766 ) {
6767 Ok(r) => r,
6768 Err(resp) => return resp,
6769 };
6770 let current_run = match load_scan_for_compare(
6771 curr_json,
6772 "current",
6773 ¤t_entry.run_id,
6774 state.server_mode,
6775 &compare_url,
6776 &csp_nonce,
6777 ) {
6778 Ok(r) => r,
6779 Err(resp) => return resp,
6780 };
6781
6782 let active_submodule = query.sub.clone();
6783 let super_scope_active = query.scope.as_deref() == Some("super");
6784
6785 let submodule_options = baseline_run
6786 .submodule_summaries
6787 .iter()
6788 .chain(current_run.submodule_summaries.iter())
6789 .map(|s| s.name.clone())
6790 .collect::<std::collections::BTreeSet<_>>()
6791 .into_iter()
6792 .collect::<Vec<_>>();
6793 let has_any_submodule_data = !submodule_options.is_empty();
6794
6795 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
6797 let mut b = baseline_run;
6798 let mut c = current_run;
6799 b.per_file_records
6800 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6801 c.per_file_records
6802 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6803 recompute_summary_from_records(&mut b);
6804 recompute_summary_from_records(&mut c);
6805 (b, c)
6806 } else if super_scope_active {
6807 let mut b = baseline_run;
6808 let mut c = current_run;
6809 b.per_file_records.retain(|f| f.submodule.is_none());
6810 c.per_file_records.retain(|f| f.submodule.is_none());
6811 recompute_summary_from_records(&mut b);
6812 recompute_summary_from_records(&mut c);
6813 (b, c)
6814 } else {
6815 (baseline_run, current_run)
6816 };
6817
6818 let comparison = compute_delta(&effective_baseline, &effective_current);
6819
6820 let file_rows: Vec<CompareFileDeltaRow> = comparison
6821 .file_deltas
6822 .iter()
6823 .map(|d| CompareFileDeltaRow {
6824 relative_path: d.relative_path.clone(),
6825 language: d.language.clone().unwrap_or_else(|| "—".into()),
6826 status: match d.status {
6827 FileChangeStatus::Added => "added".into(),
6828 FileChangeStatus::Removed => "removed".into(),
6829 FileChangeStatus::Modified => "modified".into(),
6830 FileChangeStatus::Unchanged => "unchanged".into(),
6831 },
6832 baseline_code: d.baseline_code,
6833 current_code: d.current_code,
6834 code_delta_str: fmt_delta(d.code_delta),
6835 code_delta_class: delta_class(d.code_delta).into(),
6836 comment_delta_str: fmt_delta(d.comment_delta),
6837 comment_delta_class: delta_class(d.comment_delta).into(),
6838 total_delta_str: fmt_delta(d.total_delta),
6839 total_delta_class: delta_class(d.total_delta).into(),
6840 })
6841 .collect();
6842
6843 let project_path = baseline_entry
6844 .input_roots
6845 .first()
6846 .map(|s| sanitize_path_str(s))
6847 .unwrap_or_default();
6848 let lines_added = sum_added_code_lines(&comparison);
6849 let lines_removed = sum_removed_code_lines(&comparison);
6850 let churn = compute_churn_stats(
6851 comparison.summary.baseline_code,
6852 comparison.summary.current_code,
6853 lines_added,
6854 lines_removed,
6855 );
6856 let s = &comparison.summary;
6857 let template = CompareTemplate {
6858 version: env!("CARGO_PKG_VERSION"),
6859 project_label: baseline_entry.project_label.clone(),
6860 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
6861 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
6862 baseline_run_id: baseline_entry.run_id.clone(),
6863 current_run_id: current_entry.run_id.clone(),
6864 baseline_run_id_short: baseline_entry
6865 .run_id
6866 .split('-')
6867 .next_back()
6868 .unwrap_or(&baseline_entry.run_id)
6869 .chars()
6870 .take(7)
6871 .collect(),
6872 current_run_id_short: current_entry
6873 .run_id
6874 .split('-')
6875 .next_back()
6876 .unwrap_or(¤t_entry.run_id)
6877 .chars()
6878 .take(7)
6879 .collect(),
6880 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
6881 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
6882 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
6883 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
6884 project_path: project_path.clone(),
6885 baseline_code: s.baseline_code,
6886 current_code: s.current_code,
6887 code_lines_delta_str: fmt_delta(s.code_lines_delta),
6888 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
6889 baseline_files: s.baseline_files,
6890 current_files: s.current_files,
6891 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
6892 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
6893 baseline_comments: s.baseline_comments,
6894 current_comments: s.current_comments,
6895 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
6896 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
6897 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
6898 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
6899 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
6900 code_lines_added: lines_added,
6901 code_lines_removed: lines_removed,
6902 new_scope: churn.new_scope,
6903 churn_rate_str: churn.churn_rate_str,
6904 churn_rate_class: churn.churn_rate_class,
6905 scope_flag: churn.scope_flag,
6906 files_added: comparison.files_added,
6907 files_removed: comparison.files_removed,
6908 files_modified: comparison.files_modified,
6909 files_unchanged: comparison.files_unchanged,
6910 file_rows,
6911 baseline_git_author: baseline_entry.git_author.clone(),
6912 current_git_author: current_entry.git_author.clone(),
6913 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
6914 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
6915 baseline_git_tags: baseline_entry.git_tags.clone(),
6916 current_git_tags: current_entry.git_tags.clone(),
6917 baseline_git_commit_date: baseline_entry
6918 .git_commit_date
6919 .as_deref()
6920 .and_then(fmt_git_date),
6921 current_git_commit_date: current_entry
6922 .git_commit_date
6923 .as_deref()
6924 .and_then(fmt_git_date),
6925 project_name: project_path
6926 .rsplit(['/', '\\'])
6927 .find(|s| !s.is_empty())
6928 .unwrap_or(&project_path)
6929 .to_string(),
6930 submodule_options,
6931 has_any_submodule_data,
6932 active_submodule,
6933 super_scope_active,
6934 csp_nonce,
6935 coverage_delta_card: build_coverage_delta_card(s),
6936 };
6937
6938 Html(
6939 template
6940 .render()
6941 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6942 )
6943 .into_response()
6944}
6945
6946fn format_number(n: u64) -> String {
6954 let s = n.to_string();
6955 let mut out = String::with_capacity(s.len() + s.len() / 3);
6956 let len = s.len();
6957 for (i, c) in s.chars().enumerate() {
6958 if i > 0 && (len - i).is_multiple_of(3) {
6959 out.push(',');
6960 }
6961 out.push(c);
6962 }
6963 out
6964}
6965
6966const fn badge_char_width(c: char) -> f64 {
6967 match c {
6968 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
6969 'm' | 'w' => 9.0,
6970 ' ' => 4.0,
6971 _ => 6.5,
6972 }
6973}
6974
6975#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6976fn badge_text_px(text: &str) -> u32 {
6977 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
6978}
6979
6980fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
6981 let lw = badge_text_px(label) + 20;
6982 let rw = badge_text_px(value) + 20;
6983 let total = lw + rw;
6984 let lx = lw / 2;
6985 let rx = lw + rw / 2;
6986 let le = escape_html(label);
6987 let ve = escape_html(value);
6988 let ce = escape_html(color);
6989 format!(
6990 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
6991 <rect width="{total}" height="20" fill="#555"/>
6992 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
6993 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
6994 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
6995 <text x="{lx}" y="13">{le}</text>
6996 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
6997 <text x="{rx}" y="13">{ve}</text>
6998 </g>
6999</svg>"##
7000 )
7001}
7002
7003#[derive(Deserialize)]
7004struct BadgeQuery {
7005 label: Option<String>,
7006 color: Option<String>,
7007}
7008
7009async fn badge_handler(
7010 State(state): State<AppState>,
7011 AxumPath(metric): AxumPath<String>,
7012 Query(query): Query<BadgeQuery>,
7013) -> Response {
7014 let entry = {
7015 let reg = state.registry.lock().await;
7016 reg.entries.first().cloned()
7017 };
7018
7019 let Some(entry) = entry else {
7020 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7021 return (
7022 [
7023 (header::CONTENT_TYPE, "image/svg+xml"),
7024 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7025 ],
7026 svg,
7027 )
7028 .into_response();
7029 };
7030
7031 let (default_label, value, default_color) = match metric.as_str() {
7032 "code-lines" => (
7033 "code lines",
7034 format_number(entry.summary.code_lines),
7035 "#4a78ee",
7036 ),
7037 "files" => (
7038 "files analyzed",
7039 format_number(entry.summary.files_analyzed),
7040 "#4a9862",
7041 ),
7042 "comment-lines" => (
7043 "comment lines",
7044 format_number(entry.summary.comment_lines),
7045 "#b35428",
7046 ),
7047 "blank-lines" => (
7048 "blank lines",
7049 format_number(entry.summary.blank_lines),
7050 "#7a5db0",
7051 ),
7052 _ => return StatusCode::NOT_FOUND.into_response(),
7053 };
7054
7055 let label = query.label.as_deref().unwrap_or(default_label);
7056 let color = query.color.as_deref().unwrap_or(default_color);
7057 let svg = render_badge_svg(label, &value, color);
7058
7059 (
7060 [
7061 (header::CONTENT_TYPE, "image/svg+xml"),
7062 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7063 ],
7064 svg,
7065 )
7066 .into_response()
7067}
7068
7069#[derive(Serialize)]
7077struct ApiCoverageBlock {
7078 lines_found: u64,
7079 lines_hit: u64,
7080 line_pct: f64,
7081 functions_found: u64,
7082 functions_hit: u64,
7083 function_pct: f64,
7084 branches_found: u64,
7085 branches_hit: u64,
7086 branch_pct: f64,
7087}
7088
7089#[derive(Serialize)]
7090struct ApiMetricsResponse {
7091 run_id: String,
7092 timestamp: String,
7093 project: String,
7094 summary: ApiSummaryPayload,
7095 languages: Vec<ApiLanguageRow>,
7096 #[serde(skip_serializing_if = "Option::is_none")]
7097 coverage: Option<ApiCoverageBlock>,
7098}
7099
7100#[derive(Serialize)]
7101struct ApiSummaryPayload {
7102 files_analyzed: u64,
7103 files_skipped: u64,
7104 code_lines: u64,
7105 comment_lines: u64,
7106 blank_lines: u64,
7107 total_physical_lines: u64,
7108 functions: u64,
7109 classes: u64,
7110 variables: u64,
7111 imports: u64,
7112}
7113
7114#[derive(Serialize)]
7115struct ApiLanguageRow {
7116 name: String,
7117 files: u64,
7118 code_lines: u64,
7119 comment_lines: u64,
7120 blank_lines: u64,
7121 functions: u64,
7122 classes: u64,
7123 variables: u64,
7124 imports: u64,
7125}
7126
7127async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7128 let entry = {
7129 let reg = state.registry.lock().await;
7130 reg.entries.first().cloned()
7131 };
7132 entry.map_or_else(
7133 || error::not_found("no scans recorded yet"),
7134 |e| build_metrics_response(&e),
7135 )
7136}
7137
7138async fn api_metrics_run_handler(
7139 State(state): State<AppState>,
7140 AxumPath(run_id): AxumPath<String>,
7141) -> Response {
7142 let entry = {
7143 let reg = state.registry.lock().await;
7144 reg.find_by_run_id(&run_id).cloned()
7145 };
7146 entry.map_or_else(
7147 || error::not_found("run not found"),
7148 |e| build_metrics_response(&e),
7149 )
7150}
7151
7152fn build_metrics_response(entry: &RegistryEntry) -> Response {
7153 let languages: Vec<ApiLanguageRow> = entry
7154 .json_path
7155 .as_ref()
7156 .and_then(|p| read_json(p).ok())
7157 .map(|run| {
7158 run.totals_by_language
7159 .iter()
7160 .map(|l| ApiLanguageRow {
7161 name: l.language.display_name().to_string(),
7162 files: l.files,
7163 code_lines: l.code_lines,
7164 comment_lines: l.comment_lines,
7165 blank_lines: l.blank_lines,
7166 functions: l.functions,
7167 classes: l.classes,
7168 variables: l.variables,
7169 imports: l.imports,
7170 })
7171 .collect()
7172 })
7173 .unwrap_or_default();
7174
7175 let s = &entry.summary;
7176 let coverage = if s.coverage_lines_found > 0 {
7177 let pct = |hit: u64, found: u64| -> f64 {
7178 if found == 0 {
7179 0.0
7180 } else {
7181 #[allow(clippy::cast_precision_loss)]
7182 let v = (hit as f64 / found as f64) * 100.0;
7183 (v * 10.0).round() / 10.0
7184 }
7185 };
7186 Some(ApiCoverageBlock {
7187 lines_found: s.coverage_lines_found,
7188 lines_hit: s.coverage_lines_hit,
7189 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7190 functions_found: s.coverage_functions_found,
7191 functions_hit: s.coverage_functions_hit,
7192 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7193 branches_found: s.coverage_branches_found,
7194 branches_hit: s.coverage_branches_hit,
7195 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7196 })
7197 } else {
7198 None
7199 };
7200 Json(ApiMetricsResponse {
7201 run_id: entry.run_id.clone(),
7202 timestamp: entry.timestamp_utc.to_rfc3339(),
7203 project: entry.project_label.clone(),
7204 summary: ApiSummaryPayload {
7205 files_analyzed: s.files_analyzed,
7206 files_skipped: s.files_skipped,
7207 code_lines: s.code_lines,
7208 comment_lines: s.comment_lines,
7209 blank_lines: s.blank_lines,
7210 total_physical_lines: s.total_physical_lines,
7211 functions: s.functions,
7212 classes: s.classes,
7213 variables: s.variables,
7214 imports: s.imports,
7215 },
7216 languages,
7217 coverage,
7218 })
7219 .into_response()
7220}
7221
7222#[derive(Deserialize)]
7229struct ProjectHistoryQuery {
7230 path: Option<String>,
7231}
7232
7233#[derive(Serialize)]
7234struct ProjectHistoryResponse {
7235 scan_count: usize,
7236 last_scan_id: Option<String>,
7237 last_scan_timestamp: Option<String>,
7238 last_scan_code_lines: Option<u64>,
7239 last_git_branch: Option<String>,
7240 last_git_commit: Option<String>,
7241}
7242
7243fn entry_matches_project(
7246 entry: &RegistryEntry,
7247 root_str: &str,
7248 upload_root: &str,
7249 upload_name_suffix: Option<&str>,
7250) -> bool {
7251 if entry.input_roots.iter().any(|r| r == root_str) {
7252 return true;
7253 }
7254 if let Some(suffix) = upload_name_suffix {
7255 return entry
7256 .input_roots
7257 .iter()
7258 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7259 }
7260 false
7261}
7262
7263async fn project_history_handler(
7264 State(state): State<AppState>,
7265 Query(query): Query<ProjectHistoryQuery>,
7266) -> Response {
7267 let path = query.path.unwrap_or_default();
7268 let resolved = resolve_input_path(&path);
7269 let root_str = resolved.to_string_lossy().replace('\\', "/");
7270
7271 let upload_root = std::env::temp_dir()
7276 .join("oxide-sloc-uploads")
7277 .to_string_lossy()
7278 .replace('\\', "/");
7279 let upload_name_suffix: Option<String> =
7280 if state.server_mode && root_str.starts_with(&upload_root) {
7281 resolved
7282 .file_name()
7283 .and_then(|n| n.to_str())
7284 .map(|name| format!("/{name}"))
7285 } else {
7286 None
7287 };
7288 let suffix_ref = upload_name_suffix.as_deref();
7289
7290 let entries: Vec<_> = {
7291 let reg = state.registry.lock().await;
7292 reg.entries
7293 .iter()
7294 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7295 .cloned()
7296 .collect()
7297 };
7298 let scan_count = entries.len();
7299 let last = entries.first();
7300 let last_scan_id = last.map(|e| e.run_id.clone());
7301 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7302 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7303 let last_git_branch = last.and_then(|e| e.git_branch.clone());
7304 let last_git_commit = last.and_then(|e| e.git_commit.clone());
7305
7306 Json(ProjectHistoryResponse {
7307 scan_count,
7308 last_scan_id,
7309 last_scan_timestamp,
7310 last_scan_code_lines,
7311 last_git_branch,
7312 last_git_commit,
7313 })
7314 .into_response()
7315}
7316
7317#[derive(Deserialize)]
7324struct MetricsHistoryQuery {
7325 root: Option<String>,
7326 limit: Option<usize>,
7327 submodule: Option<String>,
7330}
7331
7332#[derive(Serialize)]
7333struct MetricsSubmoduleLink {
7334 name: String,
7335 url: String,
7336}
7337
7338#[derive(Serialize)]
7339struct MetricsHistoryEntry {
7340 run_id: String,
7341 run_id_short: String,
7342 timestamp: String,
7343 commit: Option<String>,
7344 branch: Option<String>,
7345 tags: Vec<String>,
7346 nearest_tag: Option<String>,
7347 code_lines: u64,
7348 comment_lines: u64,
7349 blank_lines: u64,
7350 physical_lines: u64,
7351 files_analyzed: u64,
7352 files_skipped: u64,
7353 test_count: u64,
7354 project_label: String,
7355 html_url: Option<String>,
7356 has_pdf: bool,
7357 submodule_links: Vec<MetricsSubmoduleLink>,
7358 #[serde(skip_serializing_if = "Option::is_none")]
7360 coverage_line_pct: Option<f64>,
7361}
7362
7363fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7364 let mut links: Vec<MetricsSubmoduleLink> = vec![];
7365 let sub_dir = e
7366 .html_path
7367 .as_ref()
7368 .and_then(|p| p.parent())
7369 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7370 let Some(dir) = sub_dir else { return links };
7371 let Ok(rd) = std::fs::read_dir(dir) else {
7372 return links;
7373 };
7374 for entry_res in rd.flatten() {
7375 let fname = entry_res.file_name();
7376 let fname_str = fname.to_string_lossy();
7377 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7378 let stem = &fname_str[..fname_str.len() - 5];
7379 let display = stem[4..].replace('-', " ");
7380 links.push(MetricsSubmoduleLink {
7381 name: display,
7382 url: format!("/runs/{stem}/{}", e.run_id),
7383 });
7384 }
7385 }
7386 links.sort_by(|a, b| a.name.cmp(&b.name));
7387 links
7388}
7389
7390fn apply_submodule_filter(
7391 base: MetricsHistoryEntry,
7392 filter: &str,
7393 e: &sloc_core::history::RegistryEntry,
7394) -> Option<MetricsHistoryEntry> {
7395 let json_path = e.json_path.as_ref()?;
7396 let json_str = std::fs::read_to_string(json_path).ok()?;
7397 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7398 let sub = run
7399 .submodule_summaries
7400 .iter()
7401 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7402 let safe = sanitize_project_label(&sub.name);
7403 let artifact_key = format!("sub_{safe}");
7404 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7405 || base.html_url.clone(),
7406 |run_dir| {
7407 let sub_path = run_dir.join(format!("{artifact_key}.html"));
7408 if sub_path.exists() {
7409 Some(format!("/runs/{artifact_key}/{}", e.run_id))
7410 } else {
7411 base.html_url.clone()
7412 }
7413 },
7414 );
7415 Some(MetricsHistoryEntry {
7416 code_lines: sub.code_lines,
7417 comment_lines: sub.comment_lines,
7418 blank_lines: sub.blank_lines,
7419 physical_lines: sub.total_physical_lines,
7420 files_analyzed: sub.files_analyzed,
7421 html_url: sub_html_url,
7422 has_pdf: false,
7423 submodule_links: vec![],
7424 ..base
7425 })
7426}
7427
7428#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
7430 State(state): State<AppState>,
7431 Query(query): Query<MetricsHistoryQuery>,
7432) -> Response {
7433 let limit = query.limit.unwrap_or(50).min(500);
7434 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7435
7436 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7437 let reg = state.registry.lock().await;
7438 reg.entries
7439 .iter()
7440 .filter(|e| {
7441 query.root.as_ref().is_none_or(|root| {
7442 let resolved = resolve_input_path(root);
7443 let root_str = resolved.to_string_lossy().replace('\\', "/");
7444 e.input_roots.iter().any(|r| r == &root_str)
7445 })
7446 })
7447 .take(limit)
7448 .cloned()
7449 .collect()
7450 };
7451
7452 let entries: Vec<MetricsHistoryEntry> = candidate_entries
7453 .into_iter()
7454 .filter_map(|e| {
7455 let tags = e
7456 .git_tags
7457 .as_deref()
7458 .map(|s| {
7459 s.split(',')
7460 .map(|t| t.trim().to_string())
7461 .filter(|t| !t.is_empty())
7462 .collect()
7463 })
7464 .unwrap_or_default();
7465 let html_url = e
7466 .html_path
7467 .as_ref()
7468 .filter(|p| p.exists())
7469 .map(|_| format!("/runs/html/{}", e.run_id));
7470 let nearest_tag = e.git_nearest_tag.clone();
7471 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7472 let run_id_short: String = e
7473 .run_id
7474 .split('-')
7475 .next_back()
7476 .unwrap_or(&e.run_id)
7477 .chars()
7478 .take(7)
7479 .collect();
7480 let submodule_links = build_entry_submodule_links(&e);
7481 #[allow(clippy::cast_precision_loss)]
7482 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7483 let pct = (e.summary.coverage_lines_hit as f64
7484 / e.summary.coverage_lines_found as f64)
7485 * 100.0;
7486 Some((pct * 10.0).round() / 10.0)
7487 } else {
7488 None
7489 };
7490 let base = MetricsHistoryEntry {
7491 run_id: e.run_id.clone(),
7492 run_id_short,
7493 timestamp: e.timestamp_utc.to_rfc3339(),
7494 commit: e.git_commit.clone(),
7495 branch: e.git_branch.clone(),
7496 tags,
7497 nearest_tag,
7498 code_lines: e.summary.code_lines,
7499 comment_lines: e.summary.comment_lines,
7500 blank_lines: e.summary.blank_lines,
7501 physical_lines: e.summary.total_physical_lines,
7502 files_analyzed: e.summary.files_analyzed,
7503 files_skipped: e.summary.files_skipped,
7504 test_count: e.summary.test_count,
7505 project_label: e.project_label.clone(),
7506 html_url,
7507 has_pdf,
7508 submodule_links,
7509 coverage_line_pct,
7510 };
7511 if let Some(ref filter) = submodule_filter {
7512 apply_submodule_filter(base, filter, &e)
7513 } else {
7514 Some(base)
7515 }
7516 })
7517 .collect();
7518
7519 Json(entries).into_response()
7520}
7521
7522#[derive(Deserialize)]
7526struct MetricsSubmodulesQuery {
7527 root: Option<String>,
7528}
7529
7530#[derive(Serialize)]
7531struct SubmoduleEntry {
7532 name: String,
7533 relative_path: String,
7534}
7535
7536async fn api_metrics_submodules_handler(
7537 State(state): State<AppState>,
7538 Query(query): Query<MetricsSubmodulesQuery>,
7539) -> Response {
7540 let json_paths: Vec<std::path::PathBuf> = {
7541 let reg = state.registry.lock().await;
7542 reg.entries
7543 .iter()
7544 .filter(|e| {
7545 query.root.as_ref().is_none_or(|root| {
7546 let resolved = resolve_input_path(root);
7547 let root_str = resolved.to_string_lossy().replace('\\', "/");
7548 e.input_roots.iter().any(|r| r == &root_str)
7549 })
7550 })
7551 .filter_map(|e| e.json_path.clone())
7552 .collect()
7553 };
7554
7555 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
7556 let mut result: Vec<SubmoduleEntry> = Vec::new();
7557
7558 for path in &json_paths {
7559 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
7560 continue;
7561 };
7562 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
7563 continue;
7564 };
7565 for sub in &run.submodule_summaries {
7566 if seen.insert(sub.name.clone()) {
7567 result.push(SubmoduleEntry {
7568 name: sub.name.clone(),
7569 relative_path: sub.relative_path.clone(),
7570 });
7571 }
7572 }
7573 }
7574
7575 result.sort_by(|a, b| a.name.cmp(&b.name));
7576 Json(result).into_response()
7577}
7578
7579#[derive(Deserialize)]
7588struct IngestQuery {
7589 label: Option<String>,
7590}
7591
7592#[derive(Serialize)]
7593struct IngestResponse {
7594 run_id: String,
7595 view_url: String,
7596}
7597
7598async fn api_ingest_handler(
7599 State(state): State<AppState>,
7600 Query(q): Query<IngestQuery>,
7601 Json(run): Json<sloc_core::AnalysisRun>,
7602) -> Response {
7603 let label = q.label.unwrap_or_else(|| {
7604 run.input_roots
7605 .first()
7606 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
7607 });
7608
7609 let label_for_task = label.clone();
7610 let result = tokio::task::spawn_blocking(move || {
7611 let html = render_html(&run)?;
7612 let run_id = run.tool.run_id.clone();
7613 let run_id_safe = run_id.len() <= 128
7614 && !run_id.is_empty()
7615 && run_id
7616 .chars()
7617 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
7618 if !run_id_safe {
7619 anyhow::bail!(
7620 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
7621 );
7622 }
7623 let project_label = sanitize_project_label(&label_for_task);
7624 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
7625 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
7626 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
7627 _ => project_label,
7628 };
7629 let (artifacts, _pending_pdf) = persist_run_artifacts(
7630 &run,
7631 &html,
7632 &output_dir,
7633 &label_for_task,
7634 &file_stem,
7635 RunResultContext::default(),
7636 )?;
7637 Ok::<_, anyhow::Error>((run_id, artifacts, run))
7638 })
7639 .await;
7640
7641 match result {
7642 Ok(Ok((run_id, artifacts, run))) => {
7643 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
7644 (
7645 StatusCode::CREATED,
7646 Json(IngestResponse {
7647 view_url: format!("/view-reports?run_id={run_id}"),
7648 run_id,
7649 }),
7650 )
7651 .into_response()
7652 }
7653 Ok(Err(e)) => error::internal(&format!("{e:#}")),
7654 Err(e) => error::internal(&format!("{e}")),
7655 }
7656}
7657
7658#[allow(clippy::too_many_lines)] async fn trend_report_handler(
7666 State(state): State<AppState>,
7667 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7668) -> Response {
7669 auto_scan_watched_dirs(&state).await;
7670
7671 let watched_dirs_list: Vec<String> = {
7672 let wd = state.watched_dirs.lock().await;
7673 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7674 };
7675
7676 let roots: Vec<String> = {
7678 let reg = state.registry.lock().await;
7679 let mut seen = std::collections::BTreeSet::new();
7680 reg.entries
7681 .iter()
7682 .flat_map(|e| e.input_roots.iter().cloned())
7683 .filter(|r| seen.insert(r.clone()))
7684 .collect()
7685 };
7686
7687 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
7688 let nonce = &csp_nonce;
7689 let version = env!("CARGO_PKG_VERSION");
7690
7691 let watched_dirs_html: String = if state.server_mode {
7695 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()
7696 } else {
7697 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7698 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7699 .to_string()
7700 } else {
7701 watched_dirs_list
7702 .iter()
7703 .fold(String::new(), |mut s, d| {
7704 use std::fmt::Write as _;
7705 let escaped =
7706 d.replace('&', "&").replace('"', """).replace('<', "<");
7707 write!(
7708 s,
7709 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>"#
7710 ).expect("write to String is infallible");
7711 s
7712 })
7713 };
7714 format!(
7715 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>"#
7716 )
7717 };
7718
7719 let html = format!(
7720 r##"<!doctype html>
7721<html lang="en">
7722<head>
7723 <meta charset="utf-8" />
7724 <meta name="viewport" content="width=device-width, initial-scale=1" />
7725 <title>OxideSLOC | Trend Reports</title>
7726 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7727 <style nonce="{nonce}">
7728 :root {{
7729 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7730 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7731 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7732 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7733 --info-bg:#eef3ff; --info-text:#4467d8;
7734 }}
7735 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7736 *{{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;}}
7737 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7738 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7739 .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;}}
7740 @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));}}}}
7741 .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);}}
7742 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7743 .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));}}
7744 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7745 .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;}}
7746 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7747 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7748 @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; }} }}
7749 .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;}}
7750 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7751 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7752 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7753 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7754 .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;}}
7755 .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;}}
7756 .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;}}
7757 .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;}}
7758 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7759 .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);}}
7760 .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;}}
7761 .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;}}
7762 .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;}}
7763 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7764 .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;}}
7765 .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);}}
7766 .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;}}
7767 .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;}}
7768 .tz-select:focus{{border-color:var(--oxide);}}
7769 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
7770 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7771 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7772 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7773 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
7774 .trend-title-block{{flex:1;min-width:0;}}
7775 .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;}}
7776 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
7777 .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;}}
7778 .chart-select:focus{{border-color:var(--accent);}}
7779 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7780 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7781 .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;}}
7782 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7783 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7784 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7785 .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);}}
7786 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7787 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7788 .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;}}
7789 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
7790 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
7791 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
7792 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7793 .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;}}
7794 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
7795 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
7796 .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);}}
7797 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
7798 .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;}}
7799 .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;}}
7800 .data-table tr:last-child td{{border-bottom:none;}}
7801 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
7802 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
7803 .table-wrap{{width:100%;overflow-x:auto;}}
7804 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
7805 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
7806 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
7807 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
7808 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
7809 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
7810 .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;}}
7811 .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;}}
7812 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
7813 .pagination-info{{font-size:13px;color:var(--muted);}}
7814 .pagination-btns{{display:flex;gap:6px;}}
7815 .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;}}
7816 .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;}}
7817 #scan-history-table col:nth-child(1){{width:155px;}}
7818 #scan-history-table col:nth-child(2){{width:240px;}}
7819 #scan-history-table col:nth-child(3){{width:82px;}}
7820 #scan-history-table col:nth-child(4){{width:82px;}}
7821 #scan-history-table col:nth-child(5){{width:90px;}}
7822 #scan-history-table col:nth-child(6){{width:90px;}}
7823 #scan-history-table col:nth-child(7){{width:88px;}}
7824 #scan-history-table col:nth-child(8){{width:150px;}}
7825 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
7826 .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;}}
7827 .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;}}
7828 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
7829 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
7830 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7831 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7832 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7833 .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;}}
7834 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7835 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7836 .watched-chip-rm:hover{{color:var(--oxide);}}
7837 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7838 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7839 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7840 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7841 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
7842 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
7843 a.run-link:hover{{text-decoration:underline;}}
7844 .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);}}
7845 .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);}}
7846 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
7847 .metric-num{{font-weight:700;color:var(--text);}}
7848 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
7849 .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;}}
7850 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
7851 .btn.primary:hover{{opacity:.9;}}
7852 .rpt-btn{{min-width:58px;justify-content:center;}}
7853 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
7854 .report-cell{{overflow:visible!important;white-space:normal!important;}}
7855 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
7856 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
7857 .submod-details summary::-webkit-details-marker{{display:none;}}
7858 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
7859 .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;}}
7860 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
7861 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
7862 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
7863 .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;}}
7864 .export-btn:hover{{background:var(--line);}}
7865 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
7866 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7867 .site-footer a{{color:var(--muted);}}
7868 .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;}}
7869 .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;}}
7870 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
7871 </style>
7872</head>
7873<body>
7874 <div class="background-watermarks" aria-hidden="true">
7875 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7876 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7877 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7878 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7879 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7880 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7881 </div>
7882 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7883 <div class="top-nav">
7884 <div class="top-nav-inner">
7885 <a class="brand" href="/">
7886 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7887 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
7888 </a>
7889 <div class="nav-right">
7890 <a class="nav-pill" href="/">Home</a>
7891 <div class="nav-dropdown">
7892 <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>
7893 <div class="nav-dropdown-menu">
7894 <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>
7895 </div>
7896 </div>
7897 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7898 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
7899 <div class="nav-dropdown">
7900 <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>
7901 <div class="nav-dropdown-menu">
7902 <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>
7903 </div>
7904 </div>
7905 <div class="server-status-wrap" id="server-status-wrap">
7906 <div class="nav-pill server-online-pill" id="server-status-pill">
7907 <span class="status-dot" id="status-dot"></span>
7908 <span id="server-status-label">Server</span>
7909 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
7910 </div>
7911 <div class="server-status-tip">
7912 OxideSLOC is running — accessible on your network.
7913 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
7914 </div>
7915 </div>
7916 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7917 <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>
7918 </button>
7919 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7920 <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>
7921 <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>
7922 </button>
7923 </div>
7924 </div>
7925 </div>
7926
7927 <div class="page">
7928 {watched_dirs_html}
7929 <div class="summary-strip" id="trend-stats"></div>
7930 <div class="panel">
7931 <div class="trend-header">
7932 <div class="trend-title-block">
7933 <h1>Trend Reports</h1>
7934 <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>
7935 <span class="chart-hint-inline">
7936 <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>
7937 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
7938 </span>
7939 </div>
7940 <div class="chart-actions">
7941 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
7942 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
7943 Retention Policy
7944 </button>
7945 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
7946 <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>
7947 Clean up old runs
7948 </button>
7949 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
7950 <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>
7951 Export Excel
7952 </button>
7953 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
7954 <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>
7955 Export PNG
7956 </button>
7957 </div>
7958 </div>
7959
7960 <div class="controls-centered">
7961 <label>Project Root:
7962 <select class="chart-select" id="root-sel">
7963 <option value="">All projects</option>
7964 </select>
7965 </label>
7966 <label>Y Metric:
7967 <select class="chart-select" id="y-sel">
7968 <option value="code_lines">Code Lines</option>
7969 <option value="comment_lines">Comment Lines</option>
7970 <option value="blank_lines">Blank Lines</option>
7971 <option value="physical_lines">Physical Lines</option>
7972 <option value="files_analyzed">Files Analyzed</option>
7973 </select>
7974 </label>
7975 <label>X Axis:
7976 <select class="chart-select" id="x-sel">
7977 <option value="time">By Time</option>
7978 <option value="commit">By Commit</option>
7979 <option value="release">By Release</option>
7980 <option value="tag">Tagged Commits</option>
7981 </select>
7982 </label>
7983 <label id="submodule-label" style="display:none;">Submodule:
7984 <select class="chart-select" id="sub-sel">
7985 <option value="">All (project total)</option>
7986 </select>
7987 </label>
7988 <label>Chart Size:
7989 <select class="chart-select" id="scale-sel">
7990 <option value="0.75">Compact</option>
7991 <option value="1.2" selected>Normal</option>
7992 <option value="1.38">Large</option>
7993 </select>
7994 </label>
7995 </div>
7996
7997 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
7998 <div id="data-table-wrap" style="overflow-x:auto;"></div>
7999 </div>
8000 </div>
8001
8002 <script nonce="{nonce}">
8003 (function() {{
8004 // Theme persistence
8005 var b = document.body;
8006 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8007 var tgl = document.getElementById('theme-toggle');
8008 if (tgl) tgl.addEventListener('click', function() {{
8009 var d = b.classList.toggle('dark-theme');
8010 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8011 }});
8012
8013 // Watermark randomizer
8014 (function() {{
8015 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8016 if (!wms.length) return;
8017 var placed = [];
8018 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;}}
8019 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];}}
8020 var half=Math.floor(wms.length/2);
8021 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;}});
8022 }})();
8023
8024 // Code particles
8025 (function() {{
8026 var container = document.getElementById('code-particles');
8027 if (!container) return;
8028 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'];
8029 for (var i = 0; i < 38; i++) {{
8030 (function(idx) {{
8031 var el = document.createElement('span');
8032 el.className = 'code-particle';
8033 el.textContent = snippets[idx % snippets.length];
8034 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8035 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8036 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8037 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';
8038 container.appendChild(el);
8039 }})(i);
8040 }}
8041 }})();
8042
8043 // Watched folder picker
8044 (function() {{
8045 var btn = document.getElementById('add-watched-btn');
8046 if (!btn) return;
8047 btn.addEventListener('click', function() {{
8048 fetch('/pick-directory?kind=reports')
8049 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8050 .then(function(data) {{
8051 if (!data.cancelled && data.selected_path) {{
8052 var form = document.createElement('form');
8053 form.method = 'POST';
8054 form.action = '/watched-dirs/add';
8055 var ri = document.createElement('input');
8056 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8057 var fi = document.createElement('input');
8058 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8059 form.appendChild(ri); form.appendChild(fi);
8060 document.body.appendChild(form);
8061 form.submit();
8062 }}
8063 }})
8064 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8065 }});
8066 }})();
8067
8068 // Settings / color-scheme modal
8069 (function() {{
8070 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'}}];
8071 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);}});}}
8072 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8073 var btn=document.getElementById('settings-btn');if(!btn)return;
8074 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8075 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>';
8076 document.body.appendChild(m);
8077 var g=document.getElementById('scheme-grid');
8078 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);}});
8079 var cl=document.getElementById('settings-close');
8080 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);
8081 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');}});
8082 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8083 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8084 }})();
8085 }})();
8086
8087 var ROOTS = {roots_json};
8088 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8089 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
8090 var allData = [];
8091
8092 // Populate root selector
8093 var rootSel = document.getElementById('root-sel');
8094 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
8095
8096 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();}}
8097 function fmtFull(n){{return Number(n).toLocaleString();}}
8098 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
8099
8100 // Tooltip
8101 var tt = document.createElement('div');
8102 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);';
8103 document.body.appendChild(tt);
8104 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
8105 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';}}
8106 function hideTT(){{tt.style.display='none';}}
8107
8108 function statExact(compact, full){{
8109 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
8110 }}
8111 function statVal(n){{
8112 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
8113 }}
8114
8115 function updateStats(data){{
8116 var statsEl=document.getElementById('trend-stats');
8117 if(!statsEl)return;
8118 if(!data||!data.length){{statsEl.innerHTML='';return;}}
8119 var yKey=document.getElementById('y-sel').value;
8120 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8121 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8122 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
8123 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
8124 var absDelta=Math.abs(delta);
8125 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
8126 var deltaExact=statExact(deltaCompact,deltaFull);
8127 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
8128 statsEl.innerHTML=
8129 '<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>'+
8130 '<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>'+
8131 '<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>'+
8132 '<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>';
8133 }}
8134
8135 var subSel = document.getElementById('sub-sel');
8136 var subLabel = document.getElementById('submodule-label');
8137
8138 function populateSubmodules(root){{
8139 if(!subSel||!subLabel)return;
8140 while(subSel.options.length>1)subSel.remove(1);
8141 subSel.value='';
8142 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
8143 fetch(url)
8144 .then(function(r){{return r.json();}})
8145 .then(function(subs){{
8146 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
8147 subs.forEach(function(s){{
8148 var o=document.createElement('option');
8149 o.value=s.name;
8150 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
8151 subSel.appendChild(o);
8152 }});
8153 subLabel.style.display='';
8154 }})
8155 .catch(function(){{subLabel.style.display='none';}});
8156 }}
8157
8158 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
8159
8160 function loadAndRender(){{
8161 var root = rootSel.value;
8162 var sub = subSel ? subSel.value : '';
8163 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
8164 document.getElementById('data-table-wrap').innerHTML='';
8165 var url = '/api/metrics/history?limit=100'
8166 + (root ? '&root='+encodeURIComponent(root) : '')
8167 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
8168 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
8169 allData = data;
8170 render(data);
8171 updateStats(data);
8172 }}).catch(function(){{
8173 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>';
8174 }});
8175 }}
8176
8177 function render(data){{
8178 var yKey = document.getElementById('y-sel').value;
8179 var xMode = document.getElementById('x-sel').value;
8180
8181 // Filter for tag/release mode
8182 var pts = data;
8183 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
8184
8185 // Sort oldest-first for the line chart
8186 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8187
8188 var wrap = document.getElementById('chart-wrap');
8189 if(!pts.length){{
8190 var emptyMsg = (xMode === 'tag')
8191 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
8192 : 'No scan data found for the selected filters.';
8193 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
8194 renderTable([]);
8195 return;
8196 }}
8197
8198 var scaleEl=document.getElementById('scale-sel');
8199 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
8200 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;
8201 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
8202
8203 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8204
8205 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">';
8206 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>';
8207
8208 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
8209
8210 // Grid + Y axis ticks
8211 for(var ti=0;ti<=5;ti++){{
8212 var gy=PT+CH-Math.round(ti/5*CH);
8213 var gv=Math.round(ti/5*maxY);
8214 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
8215 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
8216 }}
8217
8218 // X axis labels (every N-th point to avoid crowding)
8219 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
8220 pts.forEach(function(d,i){{
8221 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8222 if(i%labelEvery===0||i===pts.length-1){{
8223 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)));
8224 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>';
8225 }}
8226 }});
8227
8228 // Axis label
8229 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
8230 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>';
8231 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>';
8232
8233 // Area fill + line path
8234 var pathD='';
8235 pts.forEach(function(d,i){{
8236 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8237 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8238 pathD+=(i===0?'M':'L')+x+','+y;
8239 }});
8240 if(pts.length>1){{
8241 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
8242 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
8243 }}
8244 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
8245
8246 // Data points (clickable) + permanent value labels
8247 var showLabels = pts.length <= 40;
8248 var labelEveryN = pts.length > 20 ? 2 : 1;
8249 pts.forEach(function(d,i){{
8250 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8251 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8252 var hasTags=d.tags&&d.tags.length>0;
8253 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
8254 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
8255 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+'"/>';
8256 if(showLabels && i%labelEveryN===0){{
8257 var lx=x, ly=y-r-5;
8258 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>';
8259 }}
8260 }});
8261
8262 svg+='</svg>';
8263 wrap.innerHTML=svg;
8264
8265 // Attach point tooltips
8266 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
8267 c.addEventListener('mouseover',function(e){{
8268 var d=pts[parseInt(this.dataset.idx)];
8269 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(''):'';
8270 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>':'';
8271 showTT(e,
8272 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
8273 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
8274 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
8275 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
8276 );
8277 this.setAttribute('r','8');
8278 }});
8279 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
8280 c.addEventListener('mousemove',moveTT);
8281 c.addEventListener('click',function(){{
8282 var d=pts[parseInt(this.dataset.idx)];
8283 if(d.html_url) window.open(d.html_url,'_blank');
8284 }});
8285 }});
8286
8287 renderTable(pts, yKey);
8288 }}
8289
8290 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
8291 var shProjFilter='', shBranchFilter='';
8292
8293 function fmtPST(isoStr){{
8294 if(!isoStr)return'';
8295 var d=new Date(isoStr);
8296 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
8297 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);}}
8298 function p(n){{return n<10?'0'+n:String(n);}}
8299 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++;}}}}
8300 var yr=d.getUTCFullYear();
8301 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
8302 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
8303 var isDST=d>=dstStart&&d<dstEnd;
8304 var off=isDST?-7*3600*1000:-8*3600*1000;
8305 var lbl=isDST?'PDT':'PST';
8306 var loc=new Date(d.getTime()+off);
8307 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
8308 }}
8309
8310 function getShRows(){{
8311 var proj=shProjFilter.toLowerCase().trim();
8312 var branch=shBranchFilter;
8313 return shData.filter(function(d){{
8314 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
8315 if(branch&&(d.branch||'')!==branch)return false;
8316 return true;
8317 }});
8318 }}
8319
8320 function renderShPage(){{
8321 var filtered=getShRows();
8322 if(shSortCol){{
8323 filtered.sort(function(a,b){{
8324 var va,vb;
8325 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
8326 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
8327 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
8328 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
8329 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
8330 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
8331 }});
8332 }}
8333 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
8334 shPage=Math.min(shPage,totalPages);
8335 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
8336 var visible=filtered.slice(start,end);
8337 var tbody=document.getElementById('sh-tbody');
8338 if(!tbody)return;
8339 tbody.innerHTML=visible.map(function(d){{
8340 var tsHtml=esc(fmtPST(d.timestamp));
8341 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>';
8342 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>';
8343 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
8344 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
8345 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
8346 var reportCell='';
8347 if(d.html_url){{
8348 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
8349 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>';}}
8350 reportCell+='</div>';
8351 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
8352 if(d.submodule_links&&d.submodule_links.length){{
8353 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
8354 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
8355 reportCell+='</div></details>';
8356 }}
8357 return '<tr>'
8358 +'<td>'+tsHtml+'</td>'
8359 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
8360 +'<td>'+runIdHtml+'</td>'
8361 +'<td>'+commitHtml+'</td>'
8362 +'<td>'+branchHtml+'</td>'
8363 +'<td>'+tags+'</td>'
8364 +'<td class="num">'+metricHtml+'</td>'
8365 +'<td class="report-cell">'+reportCell+'</td>'
8366 +'</tr>';
8367 }}).join('');
8368 var pgRange=document.getElementById('sh-pg-range');
8369 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
8370 var pgInfo=document.getElementById('sh-pg-info');
8371 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
8372 var pgBtns=document.getElementById('sh-pg-btns');
8373 if(pgBtns){{
8374 pgBtns.innerHTML='';
8375 function mkPgBtn(lbl,pg,active,disabled){{
8376 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
8377 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
8378 return b;
8379 }}
8380 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
8381 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
8382 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
8383 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
8384 }}
8385 }}
8386
8387 function wireTableBehavior(){{
8388 var pf=document.getElementById('sh-proj-filter');
8389 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
8390 var bf=document.getElementById('sh-branch-filter');
8391 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
8392 var rb=document.getElementById('sh-reset-btn');
8393 if(rb)rb.addEventListener('click',function(){{
8394 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
8395 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
8396 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
8397 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');}});
8398 renderShPage();
8399 }});
8400 var pps=document.getElementById('sh-per-page');
8401 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
8402 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
8403 ths.forEach(function(th){{
8404 th.addEventListener('click',function(e){{
8405 if(e.target.classList.contains('col-resize-handle'))return;
8406 var col=th.dataset.col;
8407 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
8408 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
8409 th.classList.add('sort-'+shSortOrder);
8410 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
8411 shPage=1;renderShPage();
8412 }});
8413 }});
8414 var table=document.getElementById('scan-history-table');
8415 if(!table)return;
8416 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
8417 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
8418 allThs.forEach(function(th,i){{
8419 var handle=th.querySelector('.col-resize-handle');
8420 if(!handle||!cols[i])return;
8421 var startX,startW;
8422 handle.addEventListener('mousedown',function(e){{
8423 e.stopPropagation();e.preventDefault();
8424 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
8425 handle.classList.add('dragging');
8426 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
8427 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
8428 document.addEventListener('mousemove',onMove);
8429 document.addEventListener('mouseup',onUp);
8430 }});
8431 }});
8432 }}
8433
8434 function renderTable(pts, yKey){{
8435 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
8436 var wrap=document.getElementById('data-table-wrap');
8437 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
8438 var yLabel=Y_LABELS[yKey]||yKey||'';
8439 shData=pts.slice().reverse();
8440 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
8441 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
8442 var branches={{}};
8443 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
8444 var branchOpts='<option value="">All branches</option>';
8445 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
8446 wrap.innerHTML=
8447 '<div class="chart-section-header">SCAN HISTORY</div>'+
8448 '<div class="filter-row">'+
8449 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
8450 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
8451 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
8452 '</div>'+
8453 '<div class="table-wrap">'+
8454 '<table id="scan-history-table" class="data-table">'+
8455 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
8456 '<thead><tr id="sh-thead">'+
8457 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8458 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8459 '<th>Run ID<div class="col-resize-handle"></div></th>'+
8460 '<th>Commit<div class="col-resize-handle"></div></th>'+
8461 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8462 '<th>Tags<div class="col-resize-handle"></div></th>'+
8463 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
8464 '<th>Report<div class="col-resize-handle"></div></th>'+
8465 '</tr></thead>'+
8466 '<tbody id="sh-tbody"></tbody>'+
8467 '</table>'+
8468 '</div>'+
8469 '<div class="pagination">'+
8470 '<span class="pagination-info" id="sh-pg-info"></span>'+
8471 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
8472 '<div style="display:flex;align-items:center;gap:8px;">'+
8473 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
8474 '<select class="filter-select" id="sh-per-page">'+
8475 '<option value="10">10 per page</option>'+
8476 '<option value="25" selected>25 per page</option>'+
8477 '<option value="50">50 per page</option>'+
8478 '<option value="100">100 per page</option>'+
8479 '</select>'+
8480 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
8481 '</div>'+
8482 '</div>';
8483 wireTableBehavior();
8484 renderShPage();
8485 }}
8486
8487 function exportXLSX(){{
8488 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
8489 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
8490 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
8491 var s1R=sorted.map(function(d){{
8492 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||''];
8493 }});
8494 var pm={{}};
8495 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
8496 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'];
8497 var s2R=Object.keys(pm).map(function(p){{
8498 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8499 var lat=sc[sc.length-1],fst=sc[0];
8500 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
8501 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);
8502 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];
8503 }});
8504 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
8505 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
8506 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
8507 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
8508 }}
8509
8510 function buildXLSX(sheets,chartRows,chartRows2){{
8511 function s2b(s){{return new TextEncoder().encode(s);}}
8512 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
8513 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;}}
8514 function crc32(d){{
8515 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;}}}}
8516 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
8517 }}
8518 function buildSheet(hdr,rows,drawRid,withCtrl){{
8519 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
8520 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
8521 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
8522 x+='<row r="1">';
8523 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
8524 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>';}}
8525 x+='</row>';
8526 rows.forEach(function(row,ri){{
8527 var rn=ri+2;
8528 x+='<row r="'+rn+'">';
8529 row.forEach(function(cell,ci){{
8530 var addr=col2l(ci+1)+rn;
8531 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
8532 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
8533 }});
8534 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>';}}
8535 x+='</row>';
8536 }});
8537 x+='</sheetData>';
8538 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>';}}
8539 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
8540 return x+'</worksheet>';
8541 }}
8542 function buildChartXML(rows){{
8543 var sn="'Scan History'";
8544 var nr=rows.length,er=nr+1;
8545 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'}}];
8546 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8547 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">';
8548 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8549 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8550 sd.forEach(function(s,i){{
8551 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8552 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>';
8553 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8554 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>';
8555 var dlp=(i===2)?'b':'t';
8556 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>';
8557 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8558 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8559 x+='</c:strCache></c:strRef></c:cat>';
8560 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+'"/>';
8561 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8562 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8563 }});
8564 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
8565 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>';
8566 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>';
8567 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8568 return x;
8569 }}
8570 function buildChartXML2(rows){{
8571 var sn="'By Project'";
8572 var nr=rows.length,er=nr+1;
8573 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'}}];
8574 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8575 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">';
8576 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8577 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8578 sd.forEach(function(s,i){{
8579 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8580 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>';
8581 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8582 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>';
8583 var dlp=(i===2)?'b':'t';
8584 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>';
8585 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8586 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8587 x+='</c:strCache></c:strRef></c:cat>';
8588 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+'"/>';
8589 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8590 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8591 }});
8592 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
8593 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>';
8594 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>';
8595 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8596 return x;
8597 }}
8598 function buildChartXML3(rows){{
8599 var sn="'Scan History'";
8600 var nr=rows.length,er=nr+1;
8601 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8602 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">';
8603 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
8604 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8605 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
8606 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>';
8607 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
8608 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>';
8609 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>';
8610 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8611 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8612 x+='</c:strCache></c:strRef></c:cat>';
8613 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+'"/>';
8614 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
8615 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8616 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
8617 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>';
8618 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>';
8619 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>';
8620 return x;
8621 }}
8622 var hasChart=!!(chartRows&&chartRows.length);
8623 var nr=hasChart?chartRows.length:0;
8624 var hasChart2=!!(chartRows2&&chartRows2.length);
8625 var nr2=hasChart2?chartRows2.length:0;
8626 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>';
8627 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"/>';
8628 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
8629 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"/>';}}
8630 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"/>';}}
8631 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
8632 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>';
8633 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
8634 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"/>';}});
8635 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
8636 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>';
8637 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
8638 wbx+='</sheets></workbook>';
8639 var files=[
8640 {{name:'[Content_Types].xml',data:s2b(ct)}},
8641 {{name:'_rels/.rels',data:s2b(dotrels)}},
8642 {{name:'xl/workbook.xml',data:s2b(wbx)}},
8643 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
8644 {{name:'xl/styles.xml',data:s2b(styl)}}
8645 ];
8646 // Chart embedded directly in Scan History (sheet1); By Project is plain
8647 sheets.forEach(function(s,i){{
8648 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)))}});
8649 }});
8650 if(hasChart){{
8651 var fromRow=nr+4,toRow=nr+24;
8652 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>')}});
8653 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8654 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">';
8655 drx+='<xdr:twoCellAnchor editAs="twoCell">';
8656 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>';
8657 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>';
8658 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8659 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8660 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8661 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8662 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
8663 var focRow=toRow+2,focRowEnd=toRow+22;
8664 drx+='<xdr:twoCellAnchor editAs="twoCell">';
8665 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>';
8666 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>';
8667 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8668 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8669 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8670 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
8671 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
8672 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
8673 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>')}});
8674 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
8675 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
8676 }}
8677 if(hasChart2){{
8678 var fromRow2=nr2+4,toRow2=nr2+24;
8679 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>')}});
8680 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8681 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">';
8682 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
8683 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>';
8684 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>';
8685 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8686 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8687 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8688 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8689 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
8690 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
8691 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>')}});
8692 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
8693 }}
8694 var parts=[],offsets=[],total=0;
8695 files.forEach(function(f){{
8696 offsets.push(total);
8697 var nb=s2b(f.name),crc=crc32(f.data);
8698 var h=new DataView(new ArrayBuffer(30+nb.length));
8699 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
8700 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
8701 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
8702 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
8703 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
8704 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
8705 total+=30+nb.length+f.data.length;
8706 }});
8707 var cdStart=total;
8708 files.forEach(function(f,fi){{
8709 var nb=s2b(f.name),crc=crc32(f.data);
8710 var cd=new DataView(new ArrayBuffer(46+nb.length));
8711 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
8712 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
8713 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
8714 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
8715 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
8716 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
8717 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
8718 }});
8719 var cdSz=total-cdStart;
8720 var eocd=new DataView(new ArrayBuffer(22));
8721 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
8722 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
8723 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
8724 parts.push(new Uint8Array(eocd.buffer));
8725 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
8726 var out=new Uint8Array(sz);var off=0;
8727 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
8728 return out.buffer;
8729 }}
8730
8731 function exportPNG(){{
8732 var svgEl=document.querySelector('#chart-wrap svg');
8733 if(!svgEl){{alert('No chart to export yet.');return;}}
8734 var svgStr=new XMLSerializer().serializeToString(svgEl);
8735 var vb=svgEl.viewBox.baseVal,scale=2;
8736 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
8737 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
8738 var url=URL.createObjectURL(blob);
8739 var img=new Image();
8740 img.onload=function(){{
8741 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
8742 var ctx=canvas.getContext('2d');
8743 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
8744 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
8745 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
8746 URL.revokeObjectURL(url);
8747 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
8748 }};
8749 img.src=url;
8750 }}
8751
8752 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
8753 var el=document.getElementById(id);
8754 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
8755 }});
8756 rootSel.addEventListener('change',function(){{
8757 populateSubmodules(rootSel.value);
8758 loadAndRender();
8759 }});
8760 if(subSel)subSel.addEventListener('change',loadAndRender);
8761
8762 var xlsxBtn=document.getElementById('export-xlsx-btn');
8763 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
8764 var pngBtn=document.getElementById('export-png-btn');
8765 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
8766
8767 // ── Clean-up modal ───────────────────────────────────────────────────────
8768 (function(){{
8769 var triggerBtn=document.getElementById('cleanup-runs-btn');
8770 if(!triggerBtn)return;
8771 var modal=document.createElement('div');
8772 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;';
8773 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);">'
8774 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
8775 +'<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>'
8776 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
8777 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
8778 +'<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;">'
8779 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
8780 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
8781 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
8782 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
8783 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
8784 +'</div></div>';
8785 document.body.appendChild(modal);
8786 triggerBtn.addEventListener('click',function(){{
8787 document.getElementById('cleanup-status').style.display='none';
8788 modal.style.display='flex';
8789 }});
8790 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
8791 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8792 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
8793 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
8794 var confirmBtn=this;
8795 confirmBtn.disabled=true;
8796 var status=document.getElementById('cleanup-status');
8797 status.style.display='block';
8798 status.style.background='#dbeafe';status.style.color='#1e40af';
8799 status.textContent='Deleting\u2026';
8800 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
8801 .then(function(resp){{
8802 return resp.json().then(function(d){{
8803 if(resp.ok){{
8804 status.style.background='#dcfce7';status.style.color='#166534';
8805 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
8806 setTimeout(function(){{window.location.reload();}},1500);
8807 }}else{{
8808 status.style.background='#fee2e2';status.style.color='#991b1b';
8809 status.textContent='Error: '+(d.error||'Unexpected error');
8810 confirmBtn.disabled=false;
8811 }}
8812 }});
8813 }})
8814 .catch(function(e){{
8815 status.style.background='#fee2e2';status.style.color='#991b1b';
8816 status.textContent='Network error: '+String(e);
8817 confirmBtn.disabled=false;
8818 }});
8819 }});
8820 }})();
8821
8822 // ── Retention policy panel ────────────────────────────────────────────────
8823 (function(){{
8824 var triggerBtn=document.getElementById('retention-policy-btn');
8825 if(!triggerBtn)return;
8826 var modal=document.createElement('div');
8827 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;';
8828 modal.innerHTML=''
8829 +'<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);">'
8830 +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
8831 +'<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>'
8832 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
8833 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
8834 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
8835 +'</div>'
8836 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
8837 +'<div>'
8838 +'<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>'
8839 +'<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;">'
8840 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
8841 +'</div>'
8842 +'<div>'
8843 +'<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>'
8844 +'<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;">'
8845 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
8846 +'</div>'
8847 +'</div>'
8848 +'<div style="margin-bottom:20px;">'
8849 +'<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>'
8850 +'<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;">'
8851 +'<option value="1">Every hour</option>'
8852 +'<option value="6">Every 6 hours</option>'
8853 +'<option value="12">Every 12 hours</option>'
8854 +'<option value="24" selected>Every 24 hours</option>'
8855 +'<option value="48">Every 2 days</option>'
8856 +'<option value="72">Every 3 days</option>'
8857 +'<option value="168">Every week</option>'
8858 +'</select>'
8859 +'</div>'
8860 +'<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>'
8861 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
8862 +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
8863 +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
8864 +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
8865 +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
8866 +'</div>'
8867 +'</div>';
8868 document.body.appendChild(modal);
8869
8870 function rpShowStatus(msg,ok){{
8871 var s=document.getElementById('rp-status');
8872 s.style.display='block';
8873 s.style.background=ok?'#dcfce7':'#fee2e2';
8874 s.style.color=ok?'#166534':'#991b1b';
8875 s.textContent=msg;
8876 }}
8877 function fmtAgo(iso){{
8878 if(!iso)return'Never';
8879 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
8880 if(diff<60)return diff+'s ago';
8881 if(diff<3600)return Math.floor(diff/60)+'m ago';
8882 if(diff<86400)return Math.floor(diff/3600)+'h ago';
8883 return Math.floor(diff/86400)+'d ago';
8884 }}
8885 function loadPolicy(){{
8886 fetch('/api/cleanup-policy')
8887 .then(function(r){{return r.json();}})
8888 .then(function(d){{
8889 var p=d.policy;
8890 document.getElementById('rp-enabled').checked=p?p.enabled:false;
8891 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
8892 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
8893 var sel=document.getElementById('rp-interval');
8894 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;}}}}}}
8895 var lr=document.getElementById('rp-last-run');
8896 if(d.last_run_at){{
8897 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'):'');
8898 }}else{{
8899 lr.textContent='Auto-cleanup has not run yet.';
8900 }}
8901 }})
8902 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
8903 }}
8904
8905 triggerBtn.addEventListener('click',function(){{
8906 document.getElementById('rp-status').style.display='none';
8907 loadPolicy();
8908 modal.style.display='flex';
8909 }});
8910 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
8911 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8912
8913 document.getElementById('rp-save-btn').addEventListener('click',function(){{
8914 var enabled=document.getElementById('rp-enabled').checked;
8915 var ageVal=document.getElementById('rp-max-age').value.trim();
8916 var countVal=document.getElementById('rp-max-count').value.trim();
8917 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
8918 if(enabled&&!ageVal&&!countVal){{
8919 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
8920 return;
8921 }}
8922 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
8923 var saveBtn=document.getElementById('rp-save-btn');
8924 saveBtn.disabled=true;
8925 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
8926 .then(function(r){{
8927 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
8928 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
8929 }})
8930 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
8931 .finally(function(){{saveBtn.disabled=false;}});
8932 }});
8933
8934 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
8935 var btn=this;
8936 btn.disabled=true;
8937 btn.textContent='Running\u2026';
8938 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
8939 .then(function(r){{return r.json();}})
8940 .then(function(d){{
8941 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
8942 loadPolicy();
8943 }})
8944 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
8945 .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
8946 }});
8947 }})();
8948
8949 populateSubmodules(rootSel.value);
8950 loadAndRender();
8951
8952 (function randomizeWatermarks() {{
8953 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8954 if (!wms.length) return;
8955 var placed = [];
8956 function tooClose(top, left) {{
8957 for (var i = 0; i < placed.length; i++) {{
8958 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
8959 if (dt < 16 && dl < 12) return true;
8960 }}
8961 return false;
8962 }}
8963 function pick(leftBand) {{
8964 for (var attempt = 0; attempt < 50; attempt++) {{
8965 var top = Math.random() * 88 + 2;
8966 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8967 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
8968 }}
8969 var top = Math.random() * 88 + 2;
8970 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8971 placed.push([top, left]); return [top, left];
8972 }}
8973 var half = Math.floor(wms.length / 2);
8974 wms.forEach(function (img, i) {{
8975 var pos = pick(i < half);
8976 var size = Math.floor(Math.random() * 100 + 120);
8977 var rot = (Math.random() * 360).toFixed(1);
8978 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
8979 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;
8980 }});
8981 }})();
8982 (function spawnCodeParticles() {{
8983 var container = document.getElementById('code-particles');
8984 if (!container) return;
8985 var snippets = [
8986 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
8987 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
8988 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
8989 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
8990 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
8991 ];
8992 var count = 38;
8993 for (var i = 0; i < count; i++) {{
8994 (function(idx) {{
8995 var el = document.createElement('span');
8996 el.className = 'code-particle';
8997 el.textContent = snippets[idx % snippets.length];
8998 var left = Math.random() * 94 + 2;
8999 var top = Math.random() * 88 + 6;
9000 var dur = (Math.random() * 10 + 9).toFixed(1);
9001 var delay = (Math.random() * 18).toFixed(1);
9002 var rot = (Math.random() * 26 - 13).toFixed(1);
9003 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9004 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
9005 container.appendChild(el);
9006 }})(i);
9007 }}
9008 }})();
9009 </script>
9010 <footer class="site-footer">
9011 local code analysis - metrics, history and reports
9012 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
9013 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9014 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9015 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9016 · <a href="/api-docs" rel="noopener">REST API</a>
9017 </footer>
9018 <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>
9019</body>
9020</html>"##,
9021 );
9022
9023 Html(html).into_response()
9024}
9025
9026fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9027 use std::collections::HashMap;
9028 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
9029 return vec![];
9030 }
9031 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9032 for rec in per_file_records {
9033 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9034 let e = totals.entry(lang.display_name().to_string()).or_default();
9035 e.0 += u64::from(cov.lines_found);
9036 e.1 += u64::from(cov.lines_hit);
9037 }
9038 }
9039 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
9041 .into_iter()
9042 .filter(|(_, (found, _))| *found > 0)
9043 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9044 .collect();
9045 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9046 pairs
9047 .iter()
9048 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
9049 .collect()
9050}
9051
9052fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
9053 let mut high = 0u64;
9054 let mut mid = 0u64;
9055 let mut low = 0u64;
9056 for rec in per_file_records {
9057 if let Some(cov) = &rec.coverage {
9058 if cov.lines_found == 0 {
9059 continue;
9060 }
9061 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
9062 if pct >= 80.0 {
9063 high += 1;
9064 } else if pct >= 50.0 {
9065 mid += 1;
9066 } else {
9067 low += 1;
9068 }
9069 }
9070 }
9071 (high, mid, low)
9072}
9073
9074fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9075 let mut arr: Vec<serde_json::Value> = per_file_records
9076 .iter()
9077 .filter_map(|rec| {
9078 rec.coverage.as_ref().map(|cov| {
9079 let line_pct = if cov.lines_found > 0 {
9080 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
9081 / 10.0
9082 } else {
9083 0.0
9084 };
9085 let fn_pct = if cov.functions_found > 0 {
9086 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
9087 .round()
9088 / 10.0
9089 } else {
9090 -1.0
9091 };
9092 serde_json::json!({
9093 "rel": rec.relative_path,
9094 "lang": rec.language.map_or("?", |l| l.display_name()),
9095 "line_pct": line_pct,
9096 "fn_pct": fn_pct,
9097 "lhit": cov.lines_hit,
9098 "lfound": cov.lines_found,
9099 "fhit": cov.functions_hit,
9100 "ffound": cov.functions_found,
9101 })
9102 })
9103 })
9104 .collect();
9105 arr.sort_by(|a, b| {
9106 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
9107 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
9108 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
9109 });
9110 arr
9111}
9112
9113#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
9115 let mut langs: Vec<&sloc_core::LanguageSummary> = run
9116 .totals_by_language
9117 .iter()
9118 .filter(|l| l.test_count > 0)
9119 .collect();
9120 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9121 let lang_tests: Vec<serde_json::Value> = langs
9122 .iter()
9123 .map(|l| {
9124 let d = if l.code_lines > 0 {
9125 l.test_count as f64 / l.code_lines as f64 * 1000.0
9126 } else {
9127 0.0
9128 };
9129 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9130 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9131 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9132 })
9133 .collect();
9134 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
9135 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9136 let t = &run.summary_totals;
9137 let total_tests = t.test_count;
9138 let density = if t.code_lines > 0 {
9139 total_tests as f64 / t.code_lines as f64 * 1000.0
9140 } else {
9141 0.0
9142 };
9143 let most_tested = langs.first().map_or_else(
9144 || "\u{2014}".to_string(),
9145 |l| l.language.display_name().to_string(),
9146 );
9147 let test_files: u64 = run
9148 .per_file_records
9149 .iter()
9150 .filter(|f| f.raw_line_categories.test_count > 0)
9151 .count() as u64;
9152 let cov_line = if t.coverage_lines_found > 0 {
9153 format!(
9154 "{:.1}",
9155 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
9156 )
9157 } else {
9158 "0".to_string()
9159 };
9160 let cov_fn = if t.coverage_functions_found > 0 {
9161 format!(
9162 "{:.1}",
9163 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
9164 )
9165 } else {
9166 "0".to_string()
9167 };
9168 let cov_branch = if t.coverage_branches_found > 0 {
9169 format!(
9170 "{:.1}",
9171 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
9172 )
9173 } else {
9174 "0".to_string()
9175 };
9176 let has_cov = !cov_arr.is_empty();
9177 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
9178 serde_json::json!({
9179 "totals": {
9180 "test_count": total_tests,
9181 "assertions": t.test_assertion_count,
9182 "suites": t.test_suite_count,
9183 "test_files": test_files,
9184 "total_files": t.files_analyzed,
9185 "density_str": format!("{density:.1}"),
9186 "most_tested": most_tested,
9187 "langs_with_tests": langs.len(),
9188 "cov_line": cov_line,
9189 "cov_fn": cov_fn,
9190 "cov_branch": cov_branch,
9191 },
9192 "lang_tests": lang_tests,
9193 "cov": cov_arr,
9194 "cov_tiers": {"high": high, "mid": mid, "low": low},
9195 "file_cov": file_cov_arr,
9196 "has_coverage": has_cov,
9197 "submodules": {},
9198 })
9199}
9200
9201#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
9203 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
9204 .language_summaries
9205 .iter()
9206 .filter(|l| l.test_count > 0)
9207 .collect();
9208 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9209 let lang_tests: Vec<serde_json::Value> = langs
9210 .iter()
9211 .map(|l| {
9212 let d = if l.code_lines > 0 {
9213 l.test_count as f64 / l.code_lines as f64 * 1000.0
9214 } else {
9215 0.0
9216 };
9217 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9218 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9219 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9220 })
9221 .collect();
9222 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
9223 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
9224 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
9225 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
9226 let density = if sub.code_lines > 0 {
9227 total_tests as f64 / sub.code_lines as f64 * 1000.0
9228 } else {
9229 0.0
9230 };
9231 let most_tested = langs.first().map_or_else(
9232 || "\u{2014}".to_string(),
9233 |l| l.language.display_name().to_string(),
9234 );
9235 serde_json::json!({
9236 "totals": {
9237 "test_count": total_tests,
9238 "assertions": total_assertions,
9239 "suites": total_suites,
9240 "test_files": test_files_approx,
9241 "total_files": sub.files_analyzed,
9242 "density_str": format!("{density:.1}"),
9243 "most_tested": most_tested,
9244 "langs_with_tests": langs.len(),
9245 "cov_line": "0",
9246 "cov_fn": "0",
9247 "cov_branch": "0",
9248 },
9249 "lang_tests": lang_tests,
9250 "cov": [],
9251 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
9252 "has_coverage": false,
9253 })
9254}
9255
9256fn compute_cov_json_str(run: &AnalysisRun) -> String {
9257 use std::collections::HashMap;
9258 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9259 for rec in &run.per_file_records {
9260 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9261 let e = totals.entry(lang.display_name().to_string()).or_default();
9262 e.0 += u64::from(cov.lines_found);
9263 e.1 += u64::from(cov.lines_hit);
9264 }
9265 }
9266 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
9268 .into_iter()
9269 .filter(|(_, (found, _))| *found > 0)
9270 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9271 .collect();
9272 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9273 let parts: Vec<String> = pairs
9274 .iter()
9275 .map(|(lang, pct)| {
9276 let name = lang.replace('"', "\\\"");
9277 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
9278 })
9279 .collect();
9280 format!("[{}]", parts.join(","))
9281}
9282
9283fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
9284 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9285 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
9286}
9287
9288fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
9289 let mut entry = build_test_scope_entry(run);
9290 if !run.submodule_summaries.is_empty() {
9291 let subs: serde_json::Map<String, serde_json::Value> = run
9292 .submodule_summaries
9293 .iter()
9294 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
9295 .collect();
9296 entry["submodules"] = serde_json::Value::Object(subs);
9297 }
9298 entry
9299}
9300
9301fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
9302 let name = l.language.display_name().replace('"', "\\\"");
9303 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
9305 l.test_count as f64 / l.code_lines as f64 * 1000.0
9306 } else {
9307 0.0
9308 };
9309 format!(
9310 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
9311 name = name,
9312 t = l.test_count,
9313 a = l.test_assertion_count,
9314 s = l.test_suite_count,
9315 c = l.code_lines,
9316 d = density,
9317 f = l.files,
9318 )
9319}
9320
9321fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
9322 let Some(r) = run else {
9323 return "[]".to_string();
9324 };
9325 let mut langs: Vec<&sloc_core::LanguageSummary> = r
9326 .totals_by_language
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 parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
9332 format!("[{}]", parts.join(","))
9333}
9334
9335async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
9337 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
9338 scope_map.insert(
9339 "__all__".to_string(),
9340 latest_run.map_or_else(
9341 || {
9342 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
9343 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
9344 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
9345 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
9346 "has_coverage":false,"submodules":{}})
9347 },
9348 build_test_scope_entry,
9349 ),
9350 );
9351 let all_roots: Vec<String> = {
9352 let reg = state.registry.lock().await;
9353 let mut seen = std::collections::BTreeSet::new();
9354 reg.entries
9355 .iter()
9356 .flat_map(|e| e.input_roots.iter().cloned())
9357 .filter(|r| seen.insert(r.clone()))
9358 .collect()
9359 };
9360 for root in &all_roots {
9361 let json_path = {
9362 let reg = state.registry.lock().await;
9363 reg.entries
9364 .iter()
9365 .find(|e| e.input_roots.iter().any(|r| r == root))
9366 .and_then(|e| e.json_path.clone())
9367 };
9368 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
9369 let json_str = tokio::fs::read_to_string(&p).await.ok();
9370 json_str
9371 .as_deref()
9372 .and_then(|s| serde_json::from_str(s).ok())
9373 } else {
9374 None
9375 };
9376 if let Some(ref run) = run_for_root {
9377 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
9378 }
9379 }
9380 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
9381}
9382
9383#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
9387 State(state): State<AppState>,
9388 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9389) -> Response {
9390 auto_scan_watched_dirs(&state).await;
9391 let watched_dirs_list: Vec<String> = {
9392 let wd = state.watched_dirs.lock().await;
9393 wd.dirs.iter().map(|p| p.display().to_string()).collect()
9394 };
9395 let latest_run: Option<AnalysisRun> = {
9396 let json_path = {
9397 let reg = state.registry.lock().await;
9398 reg.entries.first().and_then(|e| e.json_path.clone())
9399 };
9400 if let Some(p) = json_path {
9401 let json_str = tokio::fs::read_to_string(&p).await.ok();
9402 json_str
9403 .as_deref()
9404 .and_then(|s| serde_json::from_str(s).ok())
9405 } else {
9406 None
9407 }
9408 };
9409
9410 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
9412
9413 let cov_json: String = latest_run
9415 .as_ref()
9416 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9417 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
9418
9419 let _cov_tier_json: String = latest_run
9421 .as_ref()
9422 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9423 .map_or_else(
9424 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
9425 compute_cov_tier_json_str,
9426 );
9427
9428 let total_tests: u64 = latest_run
9429 .as_ref()
9430 .map_or(0, |r| r.summary_totals.test_count);
9431 let total_assertions: u64 = latest_run
9432 .as_ref()
9433 .map_or(0, |r| r.summary_totals.test_assertion_count);
9434 let total_suites: u64 = latest_run
9435 .as_ref()
9436 .map_or(0, |r| r.summary_totals.test_suite_count);
9437 let total_code: u64 = latest_run
9438 .as_ref()
9439 .map_or(0, |r| r.summary_totals.code_lines);
9440 let workspace_density: f64 = if total_code > 0 {
9441 total_tests as f64 / total_code as f64 * 1000.0
9442 } else {
9443 0.0
9444 };
9445 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
9446 r.totals_by_language
9447 .iter()
9448 .filter(|l| l.test_count > 0)
9449 .count()
9450 });
9451 let most_tested: String = latest_run
9452 .as_ref()
9453 .and_then(|r| {
9454 r.totals_by_language
9455 .iter()
9456 .filter(|l| l.test_count > 0)
9457 .max_by_key(|l| l.test_count)
9458 })
9459 .map_or_else(
9460 || "\u{2014}".to_string(),
9461 |l| l.language.display_name().to_string(),
9462 );
9463 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
9464 r.per_file_records
9465 .iter()
9466 .filter(|f| f.raw_line_categories.test_count > 0)
9467 .count() as u64
9468 });
9469 let total_files_analyzed: u64 = latest_run
9470 .as_ref()
9471 .map_or(0, |r| r.summary_totals.files_analyzed);
9472 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
9473
9474 let cov_line_pct_str: String = latest_run
9476 .as_ref()
9477 .filter(|r| r.summary_totals.coverage_lines_found > 0)
9478 .map_or_else(
9479 || "0".to_string(),
9480 |r| {
9481 format!(
9482 "{:.1}",
9483 r.summary_totals.coverage_lines_hit as f64
9484 / r.summary_totals.coverage_lines_found as f64
9485 * 100.0
9486 )
9487 },
9488 );
9489 let cov_fn_pct_str: String = latest_run
9490 .as_ref()
9491 .filter(|r| r.summary_totals.coverage_functions_found > 0)
9492 .map_or_else(
9493 || "0".to_string(),
9494 |r| {
9495 format!(
9496 "{:.1}",
9497 r.summary_totals.coverage_functions_hit as f64
9498 / r.summary_totals.coverage_functions_found as f64
9499 * 100.0
9500 )
9501 },
9502 );
9503 let cov_branch_pct_str: String = latest_run
9504 .as_ref()
9505 .filter(|r| r.summary_totals.coverage_branches_found > 0)
9506 .map_or_else(
9507 || "0".to_string(),
9508 |r| {
9509 format!(
9510 "{:.1}",
9511 r.summary_totals.coverage_branches_hit as f64
9512 / r.summary_totals.coverage_branches_found as f64
9513 * 100.0
9514 )
9515 },
9516 );
9517
9518 let cov_no_data_notice = if has_coverage {
9519 String::new()
9520 } else {
9521 String::from(
9522 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
9523<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>
9524<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
9525 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
9526 <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>
9527 <span style="color:var(--muted);font-size:12px;">·</span>
9528 <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>
9529 <span style="color:var(--muted);font-size:12px;">·</span>
9530 <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>
9531</div>
9532<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
9533</div>"#,
9534 )
9535 };
9536
9537 let workspace_density_str = format!("{workspace_density:.1}");
9538 let nonce = &csp_nonce;
9539 let version = env!("CARGO_PKG_VERSION");
9540
9541 let watched_dirs_html: String = if state.server_mode {
9544 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()
9545 } else {
9546 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
9547 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
9548 .to_string()
9549 } else {
9550 watched_dirs_list
9551 .iter()
9552 .fold(String::new(), |mut s, d| {
9553 use std::fmt::Write as _;
9554 let escaped =
9555 d.replace('&', "&").replace('"', """).replace('<', "<");
9556 write!(
9557 s,
9558 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>"#
9559 ).expect("write to String is infallible");
9560 s
9561 })
9562 };
9563 format!(
9564 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>"#
9565 )
9566 };
9567
9568 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
9570
9571 let html = format!(
9572 r#"<!doctype html>
9573<html lang="en">
9574<head>
9575 <meta charset="utf-8" />
9576 <meta name="viewport" content="width=device-width, initial-scale=1" />
9577 <title>OxideSLOC | Test Metrics</title>
9578 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9579 <style nonce="{nonce}">
9580 :root {{
9581 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
9582 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9583 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
9584 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9585 --info-bg:#eef3ff; --info-text:#4467d8;
9586 }}
9587 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
9588 *{{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;}}
9589 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9590 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
9591 .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;}}
9592 @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));}}}}
9593 .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);}}
9594 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
9595 .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));}}
9596 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9597 .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;}}
9598 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
9599 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
9600 @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; }} }}
9601 .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;}}
9602 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9603 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9604 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9605 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
9606 .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;}}
9607 .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;}}
9608 .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;}}
9609 .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;}}
9610 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9611 .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);}}
9612 .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;}}
9613 .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;}}
9614 .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;}}
9615 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9616 .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;}}
9617 .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);}}
9618 .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;}}
9619 .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;}}
9620 .tz-select:focus{{border-color:var(--oxide);}}
9621 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9622 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
9623 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
9624 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
9625 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
9626 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
9627 .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;}}
9628 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
9629 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
9630 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
9631 .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;}}
9632 .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;}}
9633 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
9634 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
9635 .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);}}
9636 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
9637 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
9638 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
9639 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9640 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
9641 .chart-canvas-wrap{{position:relative;height:280px;}}
9642 .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;}}
9643 .chart-no-data svg{{opacity:0.35;}}
9644 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
9645 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
9646 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9647 .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;}}
9648 .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;}}
9649 .data-table tr:last-child td{{border-bottom:none;}}
9650 .data-table tbody tr:hover td{{background:var(--surface-2);}}
9651 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
9652 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
9653 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
9654 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
9655 .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;}}
9656 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
9657 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
9658 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
9659 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
9660 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
9661 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
9662 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
9663 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
9664 .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;}}
9665 .chart-select:focus{{border-color:var(--accent);}}
9666 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
9667 .trend-canvas-wrap{{position:relative;height:260px;}}
9668 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9669 .site-footer a{{color:var(--muted);}}
9670 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
9671 .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;}}
9672 .btn:hover{{background:var(--surface-2);}}
9673 .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;}}
9674 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9675 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
9676 .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;}}
9677 .scope-sel:focus{{border-color:var(--accent);}}
9678 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
9679 .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;}}
9680 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
9681 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9682 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
9683 .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;}}
9684 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
9685 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
9686 .watched-chip-rm:hover{{color:var(--oxide);}}
9687 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
9688 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
9689 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
9690 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
9691 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
9692 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
9693 .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;}}
9694 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
9695 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
9696 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
9697 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
9698 .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;}}
9699 .cov-file-search:focus{{border-color:var(--accent);}}
9700 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
9701 .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;}}
9702 body.dark-theme .cov-file-search{{background:var(--surface);}}
9703 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9704 .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;}}
9705 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9706 .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;}}
9707 .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);}}
9708 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
9709 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
9710 .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;}}
9711 .chart-modal-close:hover{{opacity:.7;}}
9712 body.dark-theme .chart-modal{{background:var(--surface);}}
9713 </style>
9714</head>
9715<body>
9716 <div class="background-watermarks" aria-hidden="true">
9717 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9718 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9719 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9720 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9721 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9722 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9723 </div>
9724 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9725 <div class="top-nav">
9726 <div class="top-nav-inner">
9727 <a class="brand" href="/">
9728 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9729 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
9730 </a>
9731 <div class="nav-right">
9732 <a class="nav-pill" href="/">Home</a>
9733 <div class="nav-dropdown">
9734 <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>
9735 <div class="nav-dropdown-menu">
9736 <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>
9737 </div>
9738 </div>
9739 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9740 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
9741 <div class="nav-dropdown">
9742 <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>
9743 <div class="nav-dropdown-menu">
9744 <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>
9745 </div>
9746 </div>
9747 <div class="server-status-wrap" id="server-status-wrap">
9748 <div class="nav-pill server-online-pill" id="server-status-pill">
9749 <span class="status-dot" id="status-dot"></span>
9750 <span id="server-status-label">Server</span>
9751 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9752 </div>
9753 <div class="server-status-tip">
9754 OxideSLOC is running — accessible on your network.
9755 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9756 </div>
9757 </div>
9758 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9759 <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>
9760 </button>
9761 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9762 <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>
9763 <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>
9764 </button>
9765 </div>
9766 </div>
9767 </div>
9768
9769 <div class="page">
9770 {watched_dirs_html}
9771 <div class="scope-bar">
9772 <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>
9773 <span class="scope-label">Scope</span>
9774 <div class="scope-sel-wrap">
9775 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
9776 <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);">
9777 <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>
9778 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
9779 </div>
9780 </div>
9781 </div>
9782 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9783 <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>
9784 <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>
9785 <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>
9786 <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>
9787 </div>
9788 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9789 <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>
9790 <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>
9791 <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>
9792 <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>
9793 </div>
9794
9795 <div class="panel" id="viz-panel">
9796 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
9797
9798 <div class="chart-box" style="margin-bottom:18px;">
9799 <div class="chart-box-header">
9800 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
9801 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9802 </div>
9803 <p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope.</p>
9804 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
9805 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
9806 </div>
9807
9808 <div class="chart-row">
9809 <div class="chart-box">
9810 <div class="chart-box-header">
9811 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
9812 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9813 </div>
9814 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
9815 <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>
9816 </div>
9817 <div class="chart-box">
9818 <div class="chart-box-header">
9819 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
9820 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9821 </div>
9822 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
9823 <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>
9824 </div>
9825 </div>
9826
9827 <div class="chart-row">
9828 <div class="chart-box">
9829 <div class="chart-box-header">
9830 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
9831 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9832 </div>
9833 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
9834 <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>
9835 </div>
9836 <div class="chart-box" id="suites-chart-box">
9837 <div class="chart-box-header">
9838 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
9839 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
9840 </div>
9841 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
9842 <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>
9843 </div>
9844 </div>
9845
9846 <div class="chart-row">
9847 <div class="chart-box">
9848 <div class="chart-box-title">Test Files Breakdown</div>
9849 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
9850 <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>
9851 </div>
9852 <div class="chart-box">
9853 <div class="chart-box-title">Test Composition</div>
9854 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
9855 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
9856 <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>
9857 </div>
9858 </div>
9859 </div>
9860
9861 <div class="panel">
9862 <h1>Test Metrics</h1>
9863 <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>
9864
9865 <div class="section-header">Language Breakdown</div>
9866 {cov_no_data_notice}
9867 <div style="overflow-x:auto;">
9868 <table class="data-table" id="lang-table">
9869 <thead><tr>
9870 <th>Language</th>
9871 <th class="num">Test Fns</th>
9872 <th class="num">Assertions</th>
9873 <th class="num">Suites</th>
9874 <th class="num">Code Lines</th>
9875 <th class="num">Files</th>
9876 <th class="num">Density / 1K</th>
9877 <th>Relative Density</th>
9878 </tr></thead>
9879 <tbody id="lang-tbody"></tbody>
9880 </table>
9881 </div>
9882 </div>
9883
9884 <div class="panel" id="cov-panel" style="display:none;">
9885 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
9886 <div class="cov-gauge-row" id="cov-gauges">
9887 <div class="cov-gauge-card">
9888 <div class="cov-gauge-label">Line Coverage</div>
9889 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
9890 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
9891 <div class="cov-gauge-sub">Lines hit / instrumented</div>
9892 </div>
9893 <div class="cov-gauge-card">
9894 <div class="cov-gauge-label">Function Coverage</div>
9895 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
9896 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
9897 <div class="cov-gauge-sub">Functions hit / found</div>
9898 </div>
9899 <div class="cov-gauge-card">
9900 <div class="cov-gauge-label">Branch Coverage</div>
9901 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
9902 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
9903 <div class="cov-gauge-sub">Branches hit / found</div>
9904 </div>
9905 </div>
9906 <div class="chart-row">
9907 <div class="chart-box">
9908 <div class="chart-box-title">Line Coverage % by Language</div>
9909 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
9910 </div>
9911 <div class="chart-box">
9912 <div class="chart-box-title">Coverage Tier Distribution</div>
9913 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
9914 </div>
9915 </div>
9916
9917 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
9918 <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>
9919 <div class="cov-file-toolbar">
9920 <div class="cov-filter-tabs" id="cov-filter-tabs">
9921 <button class="cov-tab active" data-tier="all">All</button>
9922 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
9923 <button class="cov-tab" data-tier="low">Low (<50%)</button>
9924 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
9925 <button class="cov-tab" data-tier="high">High (≥80%)</button>
9926 </div>
9927 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
9928 </div>
9929 <div style="overflow-x:auto;">
9930 <table class="data-table" id="cov-file-table">
9931 <thead><tr>
9932 <th>File</th>
9933 <th>Lang</th>
9934 <th class="num">Line %</th>
9935 <th class="num">Lines Hit / Found</th>
9936 <th class="num">Fn %</th>
9937 <th class="num">Fns Hit / Found</th>
9938 </tr></thead>
9939 <tbody id="cov-file-tbody"></tbody>
9940 </table>
9941 </div>
9942 <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>
9943 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
9944 </div>
9945
9946 </div>
9947
9948 <footer class="site-footer">
9949 local code analysis - metrics, history and reports
9950 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
9951 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9952 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9953 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9954 · <a href="/api-docs" rel="noopener">REST API</a>
9955 </footer>
9956
9957 <script nonce="{nonce}">
9958 (function() {{
9959 // Theme
9960 var b = document.body;
9961 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
9962 var tgl = document.getElementById('theme-toggle');
9963 if (tgl) tgl.addEventListener('click', function() {{
9964 var d = b.classList.toggle('dark-theme');
9965 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
9966 }});
9967
9968 // Watermarks
9969 (function() {{
9970 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9971 if (!wms.length) return;
9972 var placed = [];
9973 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;}}
9974 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];}}
9975 var half=Math.floor(wms.length/2);
9976 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;}});
9977 }})();
9978
9979 // Code particles
9980 (function() {{
9981 var container = document.getElementById('code-particles');
9982 if (!container) return;
9983 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
9984 for (var i = 0; i < 36; i++) {{
9985 (function(idx) {{
9986 var el = document.createElement('span');
9987 el.className = 'code-particle';
9988 el.textContent = snippets[idx % snippets.length];
9989 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
9990 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
9991 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
9992 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';
9993 container.appendChild(el);
9994 }})(i);
9995 }}
9996 }})();
9997
9998 // Settings modal
9999 (function() {{
10000 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'}}];
10001 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);}});}}
10002 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10003 var btn=document.getElementById('settings-btn');if(!btn)return;
10004 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10005 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>';
10006 document.body.appendChild(m);
10007 var g=document.getElementById('scheme-grid');
10008 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);}});
10009 var cl=document.getElementById('settings-close');
10010 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');}});
10011 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10012 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10013 }})();
10014
10015 // Watched folder picker
10016 (function() {{
10017 var btn = document.getElementById('add-watched-btn');
10018 if (!btn) return;
10019 btn.addEventListener('click', function() {{
10020 fetch('/pick-directory?kind=reports')
10021 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10022 .then(function(data) {{
10023 if (!data.cancelled && data.selected_path) {{
10024 var form = document.createElement('form');
10025 form.method = 'POST';
10026 form.action = '/watched-dirs/add';
10027 var ri = document.createElement('input');
10028 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10029 var fi = document.createElement('input');
10030 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10031 form.appendChild(ri); form.appendChild(fi);
10032 document.body.appendChild(form);
10033 form.submit();
10034 }}
10035 }})
10036 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10037 }});
10038 }})();
10039 }})();
10040 </script>
10041
10042 <script src="/static/chart.js" nonce="{nonce}"></script>
10043 <script nonce="{nonce}">
10044 (function() {{
10045 var SCOPE_DATA = {scope_data_json};
10046 var currentRoot = '__all__';
10047 var currentSub = '';
10048 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
10049 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
10050 var ALL_CHARTS = [];
10051 var currentLangTests = [];
10052 var currentTrendPts = [];
10053
10054 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();}}
10055 function fmtFull(n){{return Number(n).toLocaleString();}}
10056 function isDark(){{return document.body.classList.contains('dark-theme');}}
10057 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
10058 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
10059 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
10060
10061 function makeDlPlugin(fmtFn, anchor) {{
10062 return {{
10063 afterDatasetsDraw: function(chart) {{
10064 var ctx = chart.ctx;
10065 var tc = txtClr();
10066 chart.data.datasets.forEach(function(ds, di) {{
10067 var meta = chart.getDatasetMeta(di);
10068 meta.data.forEach(function(el, idx) {{
10069 var label = fmtFn(ds.data[idx], di, idx);
10070 if (label == null || label === '') return;
10071 ctx.save();
10072 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
10073 ctx.fillStyle = tc;
10074 if (anchor === 'top') {{
10075 ctx.textAlign = 'center';
10076 ctx.textBaseline = 'bottom';
10077 ctx.fillText(String(label), el.x, el.y - 5);
10078 }} else {{
10079 ctx.textAlign = 'left';
10080 ctx.textBaseline = 'middle';
10081 ctx.fillText(String(label), el.x + 5, el.y);
10082 }}
10083 ctx.restore();
10084 }});
10085 }});
10086 }}
10087 }};
10088 }}
10089
10090 function makeTmOverlay(title, subtitle, h) {{
10091 var overlay = document.createElement('div');
10092 overlay.className = 'chart-modal-overlay';
10093 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
10094 var ch = Math.min(h || 560, maxH);
10095 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
10096 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>';
10097 document.body.appendChild(overlay);
10098 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
10099 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
10100 return document.getElementById('tm-modal-canvas');
10101 }}
10102
10103 function getDataset() {{
10104 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10105 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
10106 return r;
10107 }}
10108 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
10109
10110 function showNoData(id, show) {{
10111 var el = document.getElementById(id);
10112 if (!el) return;
10113 var wrap = el.previousElementSibling;
10114 el.style.display = show ? '' : 'none';
10115 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
10116 }}
10117
10118 function renderTestCharts(D) {{
10119 currentLangTests = D || [];
10120 testsChart = destroyChart(testsChart);
10121 densityChart = destroyChart(densityChart);
10122 if (!D || !D.length) {{
10123 showNoData('no-data-tests', true);
10124 showNoData('no-data-density', true);
10125 return;
10126 }}
10127 showNoData('no-data-tests', false);
10128 showNoData('no-data-density', false);
10129 var top15 = D.slice(0, 15);
10130 var canvas1 = document.getElementById('canvas-tests');
10131 if (canvas1) {{
10132 testsChart = new Chart(canvas1, {{
10133 type: 'bar',
10134 data: {{
10135 labels: top15.map(function(d){{ return d.lang; }}),
10136 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10137 }},
10138 options: {{
10139 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10140 layout: {{ padding: {{ right: 64 }} }},
10141 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10142 scales: {{
10143 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10144 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10145 }}
10146 }},
10147 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10148 }});
10149 ALL_CHARTS.push(testsChart);
10150 }}
10151 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
10152 var canvas2 = document.getElementById('canvas-density');
10153 if (canvas2) {{
10154 densityChart = new Chart(canvas2, {{
10155 type: 'bar',
10156 data: {{
10157 labels: topD.map(function(d){{ return d.lang; }}),
10158 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 }}]
10159 }},
10160 options: {{
10161 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10162 layout: {{ padding: {{ right: 64 }} }},
10163 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10164 scales: {{
10165 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10166 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10167 }}
10168 }},
10169 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10170 }});
10171 ALL_CHARTS.push(densityChart);
10172 }}
10173 }}
10174
10175 function renderAssertionsChart(D) {{
10176 assertionsChart = destroyChart(assertionsChart);
10177 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
10178 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10179 var canvas = document.getElementById('canvas-assertions');
10180 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
10181 showNoData('no-data-assertions', false);
10182 assertionsChart = new Chart(canvas, {{
10183 type: 'bar',
10184 data: {{
10185 labels: top15.map(function(d){{ return d.lang; }}),
10186 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10187 }},
10188 options: {{
10189 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10190 layout: {{ padding: {{ right: 64 }} }},
10191 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10192 scales: {{
10193 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10194 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10195 }}
10196 }},
10197 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10198 }});
10199 ALL_CHARTS.push(assertionsChart);
10200 }}
10201
10202 function renderSuitesChart(D) {{
10203 suitesChart = destroyChart(suitesChart);
10204 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
10205 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10206 var canvas = document.getElementById('canvas-suites');
10207 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
10208 showNoData('no-data-suites', false);
10209 suitesChart = new Chart(canvas, {{
10210 type: 'bar',
10211 data: {{
10212 labels: top15.map(function(d){{ return d.lang; }}),
10213 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 }}]
10214 }},
10215 options: {{
10216 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10217 layout: {{ padding: {{ right: 64 }} }},
10218 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10219 scales: {{
10220 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10221 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10222 }}
10223 }},
10224 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10225 }});
10226 ALL_CHARTS.push(suitesChart);
10227 }}
10228
10229 function renderFilesChart(totals) {{
10230 filesChart = destroyChart(filesChart);
10231 var canvas = document.getElementById('canvas-files');
10232 if (!canvas) return;
10233 var testF = totals.test_files || 0;
10234 var totalF = totals.total_files || 0;
10235 var nonTest = Math.max(0, totalF - testF);
10236 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
10237 showNoData('no-data-files', false);
10238 var dark = isDark();
10239 filesChart = new Chart(canvas, {{
10240 type: 'doughnut',
10241 data: {{
10242 labels: ['Test Files', 'Non-Test Files'],
10243 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
10244 }},
10245 options: {{
10246 responsive: true, maintainAspectRatio: false, cutout: '62%',
10247 plugins: {{
10248 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10249 tooltip: {{ callbacks: {{ label: function(ctx) {{
10250 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
10251 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
10252 }} }} }}
10253 }}
10254 }}
10255 }});
10256 ALL_CHARTS.push(filesChart);
10257 }}
10258
10259 function renderCompositionChart(totals) {{
10260 compositionChart = destroyChart(compositionChart);
10261 var canvas = document.getElementById('canvas-composition');
10262 if (!canvas) return;
10263 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
10264 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
10265 showNoData('no-data-composition', false);
10266 compositionChart = new Chart(canvas, {{
10267 type: 'bar',
10268 data: {{
10269 labels: ['Test Functions', 'Assertions', 'Test Suites'],
10270 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
10271 }},
10272 options: {{
10273 responsive: true, maintainAspectRatio: false,
10274 layout: {{ padding: {{ top: 22 }} }},
10275 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
10276 scales: {{
10277 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
10278 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10279 }}
10280 }},
10281 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10282 }});
10283 ALL_CHARTS.push(compositionChart);
10284 }}
10285
10286 function renderCovCharts(covD, tiers) {{
10287 covChart = destroyChart(covChart);
10288 tierChart = destroyChart(tierChart);
10289 var covCanvas = document.getElementById('canvas-cov');
10290 if (covCanvas && covD && covD.length) {{
10291 covChart = new Chart(covCanvas, {{
10292 type: 'bar',
10293 data: {{
10294 labels: covD.map(function(d){{ return d.lang; }}),
10295 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 }}]
10296 }},
10297 options: {{
10298 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10299 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
10300 scales: {{
10301 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
10302 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10303 }}
10304 }}
10305 }});
10306 ALL_CHARTS.push(covChart);
10307 }}
10308 var tierCanvas = document.getElementById('canvas-cov-tiers');
10309 if (tierCanvas && tiers) {{
10310 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
10311 tierChart = new Chart(tierCanvas, {{
10312 type: 'doughnut',
10313 data: {{
10314 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
10315 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
10316 }},
10317 options: {{
10318 responsive: true, maintainAspectRatio: false, cutout: '62%',
10319 plugins: {{
10320 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10321 tooltip: {{ callbacks: {{ label: function(ctx) {{
10322 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
10323 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
10324 }} }} }}
10325 }}
10326 }}
10327 }});
10328 ALL_CHARTS.push(tierChart);
10329 }}
10330 }}
10331
10332 function buildLangTable(D) {{
10333 var tbody = document.getElementById('lang-tbody');
10334 if (!tbody) return;
10335 if (!D || !D.length) {{
10336 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>';
10337 return;
10338 }}
10339 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
10340 tbody.innerHTML = D.map(function(d) {{
10341 var barW = Math.round(d.density / maxDensity * 120);
10342 return '<tr>' +
10343 '<td><strong>' + d.lang + '</strong></td>' +
10344 '<td class="num">' + fmt(d.tests) + '</td>' +
10345 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
10346 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
10347 '<td class="num">' + fmt(d.code) + '</td>' +
10348 '<td class="num">' + fmt(d.files) + '</td>' +
10349 '<td class="num">' + d.density.toFixed(2) + '</td>' +
10350 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
10351 '</tr>';
10352 }}).join('');
10353 }}
10354
10355 var covFileData = [];
10356 var covFileTier = 'all';
10357 var covFileSearch = '';
10358
10359 function pctBadge(pct) {{
10360 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
10361 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
10362 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
10363 }}
10364
10365 function buildCovFileTable() {{
10366 var tbody = document.getElementById('cov-file-tbody');
10367 var empty = document.getElementById('cov-file-empty');
10368 var count = document.getElementById('cov-file-count');
10369 if (!tbody) return;
10370 var srch = covFileSearch.toLowerCase();
10371 var filtered = covFileData.filter(function(f) {{
10372 if (covFileTier === 'zero' && f.line_pct > 0) return false;
10373 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
10374 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
10375 if (covFileTier === 'high' && f.line_pct < 80) return false;
10376 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
10377 return true;
10378 }});
10379 if (!filtered.length) {{
10380 tbody.innerHTML = '';
10381 if (empty) empty.style.display = '';
10382 if (count) count.textContent = '';
10383 return;
10384 }}
10385 if (empty) empty.style.display = 'none';
10386 var shown = Math.min(filtered.length, 500);
10387 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
10388 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
10389 var fnCol = f.fn_pct < 0
10390 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
10391 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
10392 return '<tr>' +
10393 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
10394 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
10395 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
10396 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
10397 fnCol +
10398 '</tr>';
10399 }}).join('');
10400 }}
10401
10402 (function() {{
10403 var tabs = document.getElementById('cov-filter-tabs');
10404 if (tabs) {{
10405 tabs.addEventListener('click', function(e) {{
10406 var btn = e.target.closest('.cov-tab');
10407 if (!btn) return;
10408 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
10409 btn.classList.add('active');
10410 covFileTier = btn.getAttribute('data-tier');
10411 buildCovFileTable();
10412 }});
10413 }}
10414 var srch = document.getElementById('cov-file-search');
10415 if (srch) {{
10416 srch.addEventListener('input', function() {{
10417 covFileSearch = this.value;
10418 buildCovFileTable();
10419 }});
10420 }}
10421 }})();
10422
10423 function updateCovGauges(t) {{
10424 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
10425 var el;
10426 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
10427 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
10428 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
10429 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
10430 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
10431 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
10432 }}
10433
10434 function applyScope() {{
10435 var d = getDataset();
10436 var t = d.totals;
10437 var el;
10438 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
10439 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
10440 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
10441 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
10442 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
10443 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
10444 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
10445 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
10446 renderTestCharts(d.lang_tests);
10447 renderAssertionsChart(d.lang_tests);
10448 renderSuitesChart(d.lang_tests);
10449 renderFilesChart(t);
10450 renderCompositionChart(t);
10451 buildLangTable(d.lang_tests);
10452 var covPanel = document.getElementById('cov-panel');
10453 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
10454 if (d.has_coverage) {{
10455 renderCovCharts(d.cov, d.cov_tiers);
10456 updateCovGauges(t);
10457 covFileData = d.file_cov || [];
10458 covFileTier = 'all';
10459 covFileSearch = '';
10460 var tabs = document.getElementById('cov-filter-tabs');
10461 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
10462 var srch = document.getElementById('cov-file-search');
10463 if (srch) srch.value = '';
10464 buildCovFileTable();
10465 }}
10466 loadTrend();
10467 }}
10468
10469 // Populate scope-root-sel from SCOPE_DATA keys
10470 (function() {{
10471 var sel = document.getElementById('scope-root-sel');
10472 if (!sel) return;
10473 Object.keys(SCOPE_DATA).forEach(function(k) {{
10474 if (k === '__all__') return;
10475 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
10476 }});
10477 }})();
10478
10479 document.getElementById('scope-root-sel').addEventListener('change', function() {{
10480 currentRoot = this.value;
10481 currentSub = '';
10482 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10483 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
10484 var subWrap = document.getElementById('scope-sub-wrap');
10485 var subSel = document.getElementById('scope-sub-sel');
10486 subSel.innerHTML = '<option value="">Entire project</option>';
10487 if (subNames.length) {{
10488 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
10489 subWrap.style.display = 'flex';
10490 }} else {{
10491 subWrap.style.display = 'none';
10492 }}
10493 applyScope();
10494 }});
10495
10496 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
10497 currentSub = this.value;
10498 applyScope();
10499 }});
10500
10501 function buildTrend(data) {{
10502 var trendCanvas = document.getElementById('canvas-trend');
10503 var trendEmpty = document.getElementById('trend-empty');
10504 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
10505 pts = pts.slice().reverse();
10506 currentTrendPts = pts;
10507 if (!pts.length) {{
10508 if (trendCanvas) trendCanvas.style.display = 'none';
10509 if (trendEmpty) trendEmpty.style.display = '';
10510 return;
10511 }}
10512 if (trendCanvas) trendCanvas.style.display = '';
10513 if (trendEmpty) trendEmpty.style.display = 'none';
10514 trendChart = destroyChart(trendChart);
10515 if (!trendCanvas) return;
10516 trendChart = new Chart(trendCanvas, {{
10517 type: 'line',
10518 data: {{
10519 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10520 datasets: [{{
10521 label: 'Test Definitions',
10522 data: pts.map(function(d){{ return d.test_count; }}),
10523 borderColor: '#C45C10',
10524 backgroundColor: 'rgba(196,92,16,0.10)',
10525 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10526 pointRadius: 5, fill: true, tension: 0.3
10527 }}]
10528 }},
10529 options: {{
10530 responsive: true, maintainAspectRatio: false,
10531 layout: {{ padding: {{ top: 22 }} }},
10532 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10533 scales: {{
10534 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
10535 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10536 }}
10537 }},
10538 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10539 }});
10540 ALL_CHARTS.push(trendChart);
10541 }}
10542
10543 // ── Full View expand buttons ──────────────────────────────────────────────
10544 (function() {{
10545 var btn = document.getElementById('tests-expand-btn');
10546 if (!btn) return;
10547 btn.addEventListener('click', function() {{
10548 var D = currentLangTests;
10549 if (!D || !D.length) return;
10550 var top15 = D.slice(0, 15);
10551 var h = Math.max(320, top15.length * 36 + 80);
10552 var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
10553 if (!canvas) return;
10554 new Chart(canvas, {{
10555 type: 'bar',
10556 data: {{
10557 labels: top15.map(function(d){{ return d.lang; }}),
10558 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10559 }},
10560 options: {{
10561 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10562 layout: {{ padding: {{ right: 72 }} }},
10563 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10564 scales: {{
10565 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10566 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10567 }}
10568 }},
10569 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10570 }});
10571 }});
10572 }})();
10573
10574 (function() {{
10575 var btn = document.getElementById('density-expand-btn');
10576 if (!btn) return;
10577 btn.addEventListener('click', function() {{
10578 var D = currentLangTests;
10579 if (!D || !D.length) return;
10580 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
10581 var h = Math.max(320, topD.length * 36 + 80);
10582 var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
10583 if (!canvas) return;
10584 new Chart(canvas, {{
10585 type: 'bar',
10586 data: {{
10587 labels: topD.map(function(d){{ return d.lang; }}),
10588 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 }}]
10589 }},
10590 options: {{
10591 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10592 layout: {{ padding: {{ right: 72 }} }},
10593 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10594 scales: {{
10595 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10596 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10597 }}
10598 }},
10599 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10600 }});
10601 }});
10602 }})();
10603
10604 (function() {{
10605 var btn = document.getElementById('trend-expand-btn');
10606 if (!btn) return;
10607 btn.addEventListener('click', function() {{
10608 var pts = currentTrendPts;
10609 if (!pts || !pts.length) return;
10610 var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
10611 if (!canvas) return;
10612 new Chart(canvas, {{
10613 type: 'line',
10614 data: {{
10615 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10616 datasets: [{{
10617 label: 'Test Definitions',
10618 data: pts.map(function(d){{ return d.test_count; }}),
10619 borderColor: '#C45C10',
10620 backgroundColor: 'rgba(196,92,16,0.10)',
10621 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10622 pointRadius: 5, fill: true, tension: 0.3
10623 }}]
10624 }},
10625 options: {{
10626 responsive: true, maintainAspectRatio: false,
10627 layout: {{ padding: {{ top: 22 }} }},
10628 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10629 scales: {{
10630 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
10631 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10632 }}
10633 }},
10634 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10635 }});
10636 }});
10637 }})();
10638
10639 (function() {{
10640 var btn = document.getElementById('assertions-expand-btn');
10641 if (!btn) return;
10642 btn.addEventListener('click', function() {{
10643 var D = currentLangTests;
10644 if (!D || !D.length) return;
10645 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10646 if (!top15.length) return;
10647 var h = Math.max(320, top15.length * 36 + 80);
10648 var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
10649 if (!canvas) return;
10650 new Chart(canvas, {{
10651 type: 'bar',
10652 data: {{
10653 labels: top15.map(function(d){{ return d.lang; }}),
10654 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10655 }},
10656 options: {{
10657 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10658 layout: {{ padding: {{ right: 72 }} }},
10659 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10660 scales: {{
10661 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10662 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10663 }}
10664 }},
10665 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10666 }});
10667 }});
10668 }})();
10669
10670 (function() {{
10671 var btn = document.getElementById('suites-expand-btn');
10672 if (!btn) return;
10673 btn.addEventListener('click', function() {{
10674 var D = currentLangTests;
10675 if (!D || !D.length) return;
10676 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10677 if (!top15.length) return;
10678 var h = Math.max(320, top15.length * 36 + 80);
10679 var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
10680 if (!canvas) return;
10681 new Chart(canvas, {{
10682 type: 'bar',
10683 data: {{
10684 labels: top15.map(function(d){{ return d.lang; }}),
10685 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 }}]
10686 }},
10687 options: {{
10688 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10689 layout: {{ padding: {{ right: 72 }} }},
10690 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10691 scales: {{
10692 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10693 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10694 }}
10695 }},
10696 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10697 }});
10698 }});
10699 }})();
10700
10701 function loadTrend() {{
10702 var url = '/api/metrics/history?limit=100';
10703 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
10704 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
10705 buildTrend(data);
10706 }}).catch(function(){{
10707 var trendEmpty = document.getElementById('trend-empty');
10708 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
10709 }});
10710 }}
10711
10712 // Re-render charts on theme toggle
10713 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
10714 setTimeout(function() {{
10715 ALL_CHARTS.forEach(function(c) {{
10716 if (c && c.options && c.options.scales) {{
10717 Object.values(c.options.scales).forEach(function(ax) {{
10718 if (ax.grid) ax.grid.color = clr();
10719 if (ax.ticks) ax.ticks.color = txtClr();
10720 }});
10721 c.update();
10722 }}
10723 }});
10724 }}, 80);
10725 }});
10726
10727 applyScope();
10728 }})();
10729 </script>
10730 <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>
10731</body>
10732</html>"#,
10733 );
10734 Html(html).into_response()
10735}
10736
10737#[derive(Deserialize)]
10744struct EmbedQuery {
10745 run_id: Option<String>,
10746 theme: Option<String>,
10747}
10748
10749async fn embed_handler(
10750 State(state): State<AppState>,
10751 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10752 Query(query): Query<EmbedQuery>,
10753) -> Response {
10754 let entry = {
10755 let reg = state.registry.lock().await;
10756 query.run_id.as_ref().map_or_else(
10757 || reg.entries.first().cloned(),
10758 |id| reg.find_by_run_id(id).cloned(),
10759 )
10760 };
10761
10762 let Some(entry) = entry else {
10763 return Html(
10764 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
10765 .to_string(),
10766 )
10767 .into_response();
10768 };
10769
10770 let dark = query.theme.as_deref() == Some("dark");
10771 let languages: Vec<(String, u64, u64)> = entry
10772 .json_path
10773 .as_ref()
10774 .and_then(|p| read_json(p).ok())
10775 .map(|run| {
10776 run.totals_by_language
10777 .iter()
10778 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
10779 .collect()
10780 })
10781 .unwrap_or_default();
10782
10783 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
10784}
10785
10786fn render_embed_widget(
10787 entry: &RegistryEntry,
10788 languages: &[(String, u64, u64)],
10789 dark: bool,
10790 csp_nonce: &str,
10791) -> String {
10792 let s = &entry.summary;
10793 let total = s.code_lines + s.comment_lines + s.blank_lines;
10794 let code_pct = s
10795 .code_lines
10796 .checked_mul(100)
10797 .and_then(|n| n.checked_div(total))
10798 .unwrap_or(0);
10799
10800 let (bg, fg, surface, muted, border) = if dark {
10801 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
10802 } else {
10803 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
10804 };
10805
10806 let mut lang_rows = String::new();
10807 for (name, files, code) in languages {
10808 write!(
10809 lang_rows,
10810 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
10811 escape_html(name),
10812 format_number(*files),
10813 format_number(*code),
10814 )
10815 .ok();
10816 }
10817
10818 let lang_table = if lang_rows.is_empty() {
10819 String::new()
10820 } else {
10821 format!(
10822 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
10823 )
10824 };
10825
10826 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
10827 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
10828 let project_esc = escape_html(&entry.project_label);
10829 let code_lines = format_number(s.code_lines);
10830 let comment_lines = format_number(s.comment_lines);
10831 let files = format_number(s.files_analyzed);
10832 let code_raw = s.code_lines;
10833 let comment_raw = s.comment_lines;
10834 let blank_raw = s.blank_lines;
10835
10836 format!(
10837 r#"<!doctype html>
10838<html lang="en">
10839<head>
10840 <meta charset="utf-8">
10841 <meta name="viewport" content="width=device-width,initial-scale=1">
10842 <title>OxideSLOC — {project_esc}</title>
10843 <script src="/static/chart.js"></script>
10844 <style nonce="{csp_nonce}">
10845 *{{box-sizing:border-box;margin:0;padding:0}}
10846 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
10847 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
10848 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
10849 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
10850 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
10851 .card .v{{font-size:18px;font-weight:700}}
10852 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
10853 .row{{display:flex;gap:12px;align-items:flex-start}}
10854 .pie{{width:120px;height:120px;flex-shrink:0}}
10855 .lt{{border-collapse:collapse;width:100%;flex:1}}
10856 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
10857 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
10858 .n{{text-align:right}}
10859 .footer{{margin-top:10px;color:{muted};font-size:10px}}
10860 </style>
10861</head>
10862<body>
10863 <h2>{project_esc}</h2>
10864 <div class="sub">{timestamp} · run {run_short}</div>
10865 <div class="cards">
10866 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
10867 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
10868 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
10869 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
10870 </div>
10871 <div class="row">
10872 <canvas class="pie" id="c"></canvas>
10873 {lang_table}
10874 </div>
10875 <div class="footer">oxide-sloc</div>
10876 <script nonce="{csp_nonce}">
10877 new Chart(document.getElementById('c'),{{
10878 type:'doughnut',
10879 data:{{
10880 labels:['Code','Comments','Blank'],
10881 datasets:[{{
10882 data:[{code_raw},{comment_raw},{blank_raw}],
10883 backgroundColor:['#4a78ee','#b35428','#aaa'],
10884 borderWidth:0
10885 }}]
10886 }},
10887 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
10888 }});
10889 </script>
10890</body>
10891</html>"#
10892 )
10893}
10894
10895fn persist_run_artifacts(
10896 run: &sloc_core::AnalysisRun,
10897 report_html: &str,
10898 run_dir: &Path,
10899 report_title: &str,
10900 file_stem: &str,
10901 result_context: RunResultContext,
10902) -> Result<(RunArtifacts, PendingPdf)> {
10903 let html_dir = run_dir.join("html");
10905 let pdf_dir = run_dir.join("pdf");
10906 let excel_dir = run_dir.join("excel");
10907 let json_dir = run_dir.join("json");
10908 let submodules_dir = run_dir.join("submodules");
10909 for dir in &[
10910 run_dir,
10911 &html_dir,
10912 &pdf_dir,
10913 &excel_dir,
10914 &json_dir,
10915 &submodules_dir,
10916 ] {
10917 fs::create_dir_all(dir)
10918 .with_context(|| format!("failed to create directory {}", dir.display()))?;
10919 }
10920
10921 let html_path = {
10923 let path = html_dir.join(format!("report_{file_stem}.html"));
10924 fs::write(&path, report_html)
10925 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
10926 Some(path)
10927 };
10928
10929 let json_path = {
10931 let path = json_dir.join(format!("result_{file_stem}.json"));
10932 let json = serde_json::to_string_pretty(run)
10933 .context("failed to serialize analysis run to JSON")?;
10934 fs::write(&path, json)
10935 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
10936 Some(path)
10937 };
10938
10939 let (pdf_path, pending_pdf) = {
10941 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
10942 match write_pdf_from_run(run, &pdf_dest) {
10943 Ok(()) => {
10944 eprintln!(
10945 "[oxide-sloc][pdf] native PDF written to {}",
10946 pdf_dest.display()
10947 );
10948 (Some(pdf_dest), None)
10949 }
10950 Err(native_err) => {
10951 eprintln!(
10952 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
10953 );
10954 let source_html_path = html_path
10955 .as_ref()
10956 .expect("html_path always Some here")
10957 .clone();
10958 let pending = Some((source_html_path, pdf_dest.clone(), false));
10959 (Some(pdf_dest), pending)
10960 }
10961 }
10962 };
10963
10964 let csv_path = {
10966 let path = excel_dir.join(format!("report_{file_stem}.csv"));
10967 if let Err(e) = sloc_report::write_csv(run, &path) {
10968 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
10969 None
10970 } else {
10971 Some(path)
10972 }
10973 };
10974
10975 let xlsx_path = {
10976 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
10977 if let Err(e) = sloc_report::write_xlsx(run, &path) {
10978 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
10979 None
10980 } else {
10981 Some(path)
10982 }
10983 };
10984
10985 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
10987
10988 if run.effective_configuration.discovery.submodule_breakdown {
10990 let run_id = &run.tool.run_id;
10991 for s in &run.submodule_summaries {
10992 build_submodule_row(s, run, run_id, run_dir);
10993 }
10994 }
10995
10996 generate_offline_index(
10998 run,
10999 run_dir,
11000 file_stem,
11001 html_path.as_deref(),
11002 pdf_path.as_deref(),
11003 json_path.as_deref(),
11004 scan_config_path.as_deref(),
11005 &result_context,
11006 );
11007
11008 Ok((
11009 RunArtifacts {
11010 output_dir: run_dir.to_path_buf(),
11011 html_path,
11012 pdf_path,
11013 json_path,
11014 csv_path,
11015 xlsx_path,
11016 scan_config_path,
11017 report_title: report_title.to_string(),
11018 result_context,
11019 },
11020 pending_pdf,
11021 ))
11022}
11023
11024#[allow(clippy::too_many_arguments)]
11027#[allow(clippy::too_many_lines)]
11028fn generate_offline_index(
11029 run: &sloc_core::AnalysisRun,
11030 run_dir: &Path,
11031 file_stem: &str,
11032 html_path: Option<&Path>,
11033 pdf_path: Option<&Path>,
11034 json_path: Option<&Path>,
11035 scan_config_path: Option<&Path>,
11036 result_context: &RunResultContext,
11037) {
11038 let prev_entry = &result_context.prev_entry;
11039 let prev_scan_count = result_context.prev_scan_count;
11040 let project_path = &result_context.project_path;
11041
11042 let scan_delta = prev_entry.as_ref().and_then(|prev| {
11043 prev.json_path
11044 .as_ref()
11045 .and_then(|p| read_json(p).ok())
11046 .map(|prev_run| compute_delta(&prev_run, run))
11047 });
11048
11049 let files_analyzed = run.per_file_records.len() as u64;
11050 let files_skipped = run.skipped_file_records.len() as u64;
11051 let physical_lines = run
11052 .totals_by_language
11053 .iter()
11054 .map(|r| r.total_physical_lines)
11055 .sum::<u64>();
11056 let code_lines = run
11057 .totals_by_language
11058 .iter()
11059 .map(|r| r.code_lines)
11060 .sum::<u64>();
11061 let comment_lines = run
11062 .totals_by_language
11063 .iter()
11064 .map(|r| r.comment_lines)
11065 .sum::<u64>();
11066 let blank_lines = run
11067 .totals_by_language
11068 .iter()
11069 .map(|r| r.blank_lines)
11070 .sum::<u64>();
11071 let mixed_lines = run
11072 .totals_by_language
11073 .iter()
11074 .map(|r| r.mixed_lines_separate)
11075 .sum::<u64>();
11076 let functions = run
11077 .totals_by_language
11078 .iter()
11079 .map(|r| r.functions)
11080 .sum::<u64>();
11081 let classes = run
11082 .totals_by_language
11083 .iter()
11084 .map(|r| r.classes)
11085 .sum::<u64>();
11086 let variables = run
11087 .totals_by_language
11088 .iter()
11089 .map(|r| r.variables)
11090 .sum::<u64>();
11091 let imports = run
11092 .totals_by_language
11093 .iter()
11094 .map(|r| r.imports)
11095 .sum::<u64>();
11096
11097 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
11098 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
11099 let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
11100 let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
11101 let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
11102 let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
11103 let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
11104 let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
11105
11106 let (delta_fa_str, delta_fa_class) =
11107 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
11108 let (delta_fs_str, delta_fs_class) =
11109 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
11110 let (delta_pl_str, delta_pl_class) =
11111 summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
11112 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
11113 let (delta_cml_str, delta_cml_class) =
11114 summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
11115 let (delta_bl_str, delta_bl_class) =
11116 summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
11117
11118 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
11119 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
11120 let (delta_lines_net_str, delta_lines_net_class) =
11121 match (delta_lines_added, delta_lines_removed) {
11122 (Some(a), Some(r)) => {
11123 let net = a - r;
11124 (fmt_delta(net), delta_class(net).to_string())
11125 }
11126 _ => ("\u{2014}".to_string(), "na".to_string()),
11127 };
11128
11129 let git_commit_url = run
11130 .git_remote_url
11131 .as_deref()
11132 .zip(run.git_commit_long.as_deref())
11133 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
11134 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
11135 format!(
11136 "{} / {}",
11137 run.environment.initiator_username, run.environment.initiator_hostname
11138 )
11139 });
11140
11141 let make_rel = |p: Option<&Path>| -> Option<String> {
11143 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
11144 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
11145 };
11146
11147 let run_id = &run.tool.run_id;
11148
11149 let submodule_rows: Vec<SubmoduleRow> = run
11151 .submodule_summaries
11152 .iter()
11153 .map(|s| {
11154 let safe = sanitize_project_label(&s.name);
11155 let key = format!("sub_{safe}");
11156 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
11157 SubmoduleRow {
11158 name: s.name.clone(),
11159 relative_path: s.relative_path.clone(),
11160 files_analyzed: s.files_analyzed,
11161 code_lines: s.code_lines,
11162 comment_lines: s.comment_lines,
11163 blank_lines: s.blank_lines,
11164 total_physical_lines: s.total_physical_lines,
11165 html_url: if sub_path.exists() {
11166 Some(format!("submodules/{key}.html"))
11167 } else {
11168 None
11169 },
11170 }
11171 })
11172 .collect();
11173
11174 let lang_chart_json = {
11175 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
11176 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
11177 let entries: Vec<String> = langs
11178 .into_iter()
11179 .take(12)
11180 .map(|l| {
11181 let name = l.language.display_name()
11182 .replace('\\', "\\\\")
11183 .replace('"', "\\\"");
11184 format!(
11185 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
11186 name, l.code_lines, l.comment_lines, l.blank_lines,
11187 l.total_physical_lines, l.functions, l.classes,
11188 l.variables, l.imports, l.files
11189 )
11190 })
11191 .collect();
11192 format!("[{}]", entries.join(","))
11193 };
11194
11195 let scan_config_rel =
11196 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
11197
11198 let template = ResultTemplate {
11199 version: env!("CARGO_PKG_VERSION"),
11200 report_title: run.effective_configuration.reporting.report_title.clone(),
11201 project_path: project_path.clone(),
11202 output_dir: display_path(run_dir),
11203 run_id: run_id.clone(),
11204 run_id_short: run_id
11205 .split('-')
11206 .next_back()
11207 .unwrap_or(run_id)
11208 .chars()
11209 .take(7)
11210 .collect(),
11211 files_analyzed,
11212 files_skipped,
11213 physical_lines,
11214 code_lines,
11215 comment_lines,
11216 blank_lines,
11217 mixed_lines,
11218 functions,
11219 classes,
11220 variables,
11221 imports,
11222 html_url: make_rel(html_path),
11223 pdf_url: make_rel(pdf_path),
11224 json_url: make_rel(json_path),
11225 html_download_url: make_rel(html_path),
11226 pdf_download_url: make_rel(pdf_path),
11227 json_download_url: make_rel(json_path),
11228 html_path: html_path.map(display_path),
11229 json_path: json_path.map(display_path),
11230 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
11231 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
11232 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
11233 prev_fa_str,
11234 prev_fs_str,
11235 prev_pl_str,
11236 prev_cl_str,
11237 prev_cml_str,
11238 prev_bl_str,
11239 delta_fa_str,
11240 delta_fa_class: delta_fa_class.to_string(),
11241 delta_fs_str,
11242 delta_fs_class: delta_fs_class.to_string(),
11243 delta_pl_str,
11244 delta_pl_class: delta_pl_class.to_string(),
11245 delta_cl_str,
11246 delta_cl_class: delta_cl_class.to_string(),
11247 delta_cml_str,
11248 delta_cml_class: delta_cml_class.to_string(),
11249 delta_bl_str,
11250 delta_bl_class: delta_bl_class.to_string(),
11251 delta_lines_added,
11252 delta_lines_removed,
11253 delta_lines_net_str,
11254 delta_lines_net_class,
11255 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
11256 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
11257 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
11258 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
11259 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
11260 d.file_deltas
11261 .iter()
11262 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
11263 .map(|f| {
11264 #[allow(clippy::cast_sign_loss)]
11265 let n = f.current_code as u64;
11266 n
11267 })
11268 .sum()
11269 }),
11270 git_branch: run.git_branch.clone(),
11271 git_commit: run.git_commit_short.clone(),
11272 git_commit_long: run.git_commit_long.clone(),
11273 git_author: run.git_commit_author.clone(),
11274 git_commit_url,
11275 scan_performed_by,
11276 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
11277 os_display: format!(
11278 "{} / {}",
11279 run.environment.operating_system, run.environment.architecture
11280 ),
11281 test_count: run.summary_totals.test_count,
11282 current_scan_number: prev_scan_count + 1,
11283 prev_scan_count,
11284 submodule_rows,
11285 pdf_generating: false,
11286 scan_config_url: scan_config_rel,
11287 lang_chart_json,
11288 scatter_chart_json: String::new(),
11289 semantic_chart_json: String::new(),
11290 submodule_chart_json: String::new(),
11291 has_submodule_data: !run.submodule_summaries.is_empty(),
11292 has_semantic_data: run
11293 .totals_by_language
11294 .iter()
11295 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
11296 csp_nonce: String::new(),
11297 confluence_configured: false,
11298 server_mode: false,
11299 report_header_footer: run
11300 .effective_configuration
11301 .reporting
11302 .report_header_footer
11303 .clone(),
11304 is_offline: true,
11305 };
11306
11307 if let Ok(html) = template.render() {
11308 let index_path = run_dir.join("index.html");
11309 if let Err(e) = fs::write(&index_path, html) {
11310 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
11311 }
11312 }
11313}
11314
11315fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
11318 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
11320 return Some(found);
11321 }
11322 find_scan_config_in_dir_flat(dir)
11324}
11325
11326fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
11327 let exact = dir.join("scan-config.json");
11328 if exact.exists() {
11329 return Some(exact);
11330 }
11331 fs::read_dir(dir).ok().and_then(|entries| {
11332 entries
11333 .filter_map(std::result::Result::ok)
11334 .find(|e| {
11335 let name = e.file_name();
11336 let name = name.to_string_lossy();
11337 name.starts_with("scan-config") && name.ends_with(".json")
11338 })
11339 .map(|e| e.path())
11340 })
11341}
11342
11343async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
11346 let toml_str = match toml::to_string_pretty(&state.base_config) {
11347 Ok(s) => s,
11348 Err(e) => {
11349 return (
11350 StatusCode::INTERNAL_SERVER_ERROR,
11351 format!("serialization error: {e}"),
11352 )
11353 .into_response();
11354 }
11355 };
11356 (
11357 [
11358 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
11359 (
11360 header::CONTENT_DISPOSITION,
11361 "attachment; filename=\".oxide-sloc.toml\"",
11362 ),
11363 ],
11364 toml_str,
11365 )
11366 .into_response()
11367}
11368
11369#[derive(Serialize)]
11370struct OkResponse {
11371 ok: bool,
11372}
11373
11374#[derive(Serialize)]
11375struct SaveProfileResponse {
11376 ok: bool,
11377 id: String,
11378}
11379
11380#[derive(Serialize)]
11381struct ProfileListResponse {
11382 profiles: Vec<ScanProfile>,
11383}
11384
11385#[derive(Serialize)]
11386struct ImportConfigResponse {
11387 ok: bool,
11388 config: sloc_config::AppConfig,
11389}
11390
11391#[derive(Deserialize)]
11392struct ImportConfigBody {
11393 toml: String,
11394}
11395
11396async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
11397 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
11398 Ok(config) => {
11399 if let Err(e) = config.validate() {
11400 return error::unprocessable_entity(&e.to_string());
11401 }
11402 Json(ImportConfigResponse { ok: true, config }).into_response()
11403 }
11404 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
11405 }
11406}
11407
11408async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
11411 let store = state.scan_profiles.lock().await;
11412 Json(ProfileListResponse {
11413 profiles: store.profiles.clone(),
11414 })
11415}
11416
11417#[derive(Deserialize)]
11418struct SaveScanProfileBody {
11419 name: String,
11420 params: serde_json::Value,
11421}
11422
11423async fn api_save_scan_profile(
11424 State(state): State<AppState>,
11425 Json(body): Json<SaveScanProfileBody>,
11426) -> impl IntoResponse {
11427 if body.name.trim().is_empty() {
11428 return error::bad_request("name must not be empty");
11429 }
11430
11431 let id = uuid::Uuid::new_v4().to_string();
11432 let profile = ScanProfile {
11433 id: id.clone(),
11434 name: body.name.trim().to_string(),
11435 created_at: chrono::Utc::now().to_rfc3339(),
11436 params: body.params,
11437 };
11438
11439 let mut store = state.scan_profiles.lock().await;
11440 store.profiles.push(profile);
11441 if let Err(e) = store.save(&state.scan_profiles_path) {
11442 tracing::warn!("failed to persist scan profiles: {e}");
11443 }
11444 drop(store);
11445
11446 (
11447 StatusCode::CREATED,
11448 Json(SaveProfileResponse { ok: true, id }),
11449 )
11450 .into_response()
11451}
11452
11453async fn api_delete_scan_profile(
11454 State(state): State<AppState>,
11455 AxumPath(id): AxumPath<String>,
11456) -> impl IntoResponse {
11457 let mut store = state.scan_profiles.lock().await;
11458 let before = store.profiles.len();
11459 store.profiles.retain(|p| p.id != id);
11460 if store.profiles.len() == before {
11461 drop(store);
11462 return error::not_found("profile not found");
11463 }
11464 if let Err(e) = store.save(&state.scan_profiles_path) {
11465 tracing::warn!("failed to persist scan profiles: {e}");
11466 }
11467 drop(store);
11468 Json(OkResponse { ok: true }).into_response()
11469}
11470
11471fn resolve_output_root(raw: Option<&str>) -> PathBuf {
11472 let value = raw.unwrap_or("out/web").trim();
11473 let path = if value.is_empty() {
11474 PathBuf::from("out/web")
11475 } else {
11476 PathBuf::from(value)
11477 };
11478
11479 if path.is_absolute() {
11480 path
11481 } else {
11482 workspace_root().join(path)
11483 }
11484}
11485
11486fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
11488 std::env::var("SLOC_GIT_CLONES_DIR")
11489 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
11490}
11491
11492pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
11495 let safe: String = repo_url
11496 .chars()
11497 .map(|c| {
11498 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
11499 c
11500 } else {
11501 '_'
11502 }
11503 })
11504 .take(80)
11505 .collect();
11506 clones_dir.join(safe)
11507}
11508
11509pub(crate) fn scan_path_to_artifacts(
11512 scan_path: &Path,
11513 base_config: &AppConfig,
11514 label: &str,
11515) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
11516 let mut config = base_config.clone();
11517 config.discovery.root_paths = vec![scan_path.to_path_buf()];
11518 label.clone_into(&mut config.reporting.report_title);
11519 let run = analyze(&config, "git", None, None)?;
11520 let html = render_html(&run)?;
11521 let run_id = run.tool.run_id.clone();
11522 let project_label = sanitize_project_label(label);
11523 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
11524 let file_stem = {
11525 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
11526 if commit.is_empty() {
11527 project_label
11528 } else {
11529 format!("{project_label}_{commit}")
11530 }
11531 };
11532 let (artifacts, _pending_pdf) = persist_run_artifacts(
11533 &run,
11534 &html,
11535 &output_dir,
11536 label,
11537 &file_stem,
11538 RunResultContext::default(),
11539 )?;
11540 Ok((run_id, artifacts, run))
11541}
11542
11543async fn restart_poll_schedules(state: &AppState) {
11545 let store = state.schedules.lock().await;
11546 let poll_schedules: Vec<_> = store
11547 .schedules
11548 .iter()
11549 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
11550 .cloned()
11551 .collect();
11552 drop(store);
11553 for schedule in poll_schedules {
11554 let interval = schedule.interval_secs.unwrap_or(300);
11555 let st = state.clone();
11556 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
11557 }
11558}
11559
11560fn split_patterns(raw: Option<&str>) -> Vec<String> {
11561 raw.unwrap_or("")
11562 .lines()
11563 .flat_map(|line| line.split(','))
11564 .map(str::trim)
11565 .filter(|part| !part.is_empty())
11566 .map(ToOwned::to_owned)
11567 .collect()
11568}
11569
11570pub fn build_sub_run(
11571 parent: &AnalysisRun,
11572 sub: &sloc_core::SubmoduleSummary,
11573 parent_path: &str,
11574) -> AnalysisRun {
11575 let sub_files: Vec<_> = parent
11576 .per_file_records
11577 .iter()
11578 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
11579 .cloned()
11580 .collect();
11581 let mut config = parent.effective_configuration.clone();
11582 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
11583 AnalysisRun {
11584 tool: parent.tool.clone(),
11585 environment: parent.environment.clone(),
11586 effective_configuration: config,
11587 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
11588 summary_totals: SummaryTotals {
11589 files_considered: sub.files_analyzed,
11590 files_analyzed: sub.files_analyzed,
11591 files_skipped: 0,
11592 total_physical_lines: sub.total_physical_lines,
11593 code_lines: sub.code_lines,
11594 comment_lines: sub.comment_lines,
11595 blank_lines: sub.blank_lines,
11596 mixed_lines_separate: 0,
11597 functions: 0,
11598 classes: 0,
11599 variables: 0,
11600 imports: 0,
11601 test_count: 0,
11602 test_assertion_count: 0,
11603 test_suite_count: 0,
11604 coverage_lines_found: 0,
11605 coverage_lines_hit: 0,
11606 coverage_functions_found: 0,
11607 coverage_functions_hit: 0,
11608 coverage_branches_found: 0,
11609 coverage_branches_hit: 0,
11610 },
11611 totals_by_language: sub.language_summaries.clone(),
11612 per_file_records: sub_files,
11613 skipped_file_records: vec![],
11614 warnings: vec![],
11615 submodule_summaries: vec![],
11616 git_commit_short: parent.git_commit_short.clone(),
11617 git_commit_long: parent.git_commit_long.clone(),
11618 git_branch: parent.git_branch.clone(),
11619 git_commit_author: parent.git_commit_author.clone(),
11620 git_commit_date: parent.git_commit_date.clone(),
11621 git_tags: parent.git_tags.clone(),
11622 git_nearest_tag: parent.git_nearest_tag.clone(),
11623 git_remote_url: parent.git_remote_url.clone(),
11624 style_summary: None,
11625 }
11626}
11627
11628pub fn sanitize_project_label(raw: &str) -> String {
11629 let candidate = Path::new(raw)
11630 .file_name()
11631 .and_then(|name| name.to_str())
11632 .unwrap_or("project");
11633
11634 let mut value = String::with_capacity(candidate.len());
11635 for ch in candidate.chars() {
11636 if ch.is_ascii_alphanumeric() {
11637 value.push(ch.to_ascii_lowercase());
11638 } else {
11639 value.push('-');
11640 }
11641 }
11642
11643 let compact = value.trim_matches('-').to_string();
11644 if compact.is_empty() {
11645 "project".to_string()
11646 } else {
11647 compact
11648 }
11649}
11650
11651fn strip_unc_prefix(path: PathBuf) -> PathBuf {
11654 let s = path.to_string_lossy();
11655 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11656 return PathBuf::from(format!(r"\\{rest}"));
11657 }
11658 if let Some(rest) = s.strip_prefix(r"\\?\") {
11659 return PathBuf::from(rest);
11660 }
11661 path
11662}
11663
11664fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
11667 let base = if let Some(rest) = remote.strip_prefix("git@") {
11668 let (host, path) = rest.split_once(':')?;
11669 format!("https://{}/{}", host, path.trim_end_matches(".git"))
11670 } else if remote.starts_with("https://") || remote.starts_with("http://") {
11671 remote
11672 .trim_end_matches('/')
11673 .trim_end_matches(".git")
11674 .to_owned()
11675 } else {
11676 return None;
11677 };
11678 let base = base.trim_end_matches('/');
11679 if base.contains("gitlab.com") || base.contains("gitlab.") {
11681 Some(format!("{}/-/commit/{}", base, sha))
11682 } else if base.contains("bitbucket.org") {
11683 Some(format!("{}/commits/{}", base, sha))
11684 } else {
11685 Some(format!("{}/commit/{}", base, sha))
11686 }
11687}
11688
11689fn display_path(path: &Path) -> String {
11690 let s = path.to_string_lossy();
11691 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11696 return format!(r"\\{rest}");
11697 }
11698 if let Some(rest) = s.strip_prefix(r"\\?\") {
11699 return rest.to_owned();
11700 }
11701 s.into_owned()
11702}
11703
11704fn sanitize_path_str(s: &str) -> String {
11705 if let Some(rest) = s.strip_prefix("//?/UNC/") {
11709 return format!("//{rest}");
11710 }
11711 if let Some(rest) = s.strip_prefix("//?/") {
11712 return rest.to_owned();
11713 }
11714 display_path(Path::new(s))
11715}
11716
11717fn workspace_root() -> PathBuf {
11718 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
11720 let p = PathBuf::from(root);
11721 if p.is_dir() {
11722 return p;
11723 }
11724 }
11725
11726 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
11729}
11730
11731fn make_git_label(repo: &str, ref_name: &str) -> String {
11733 if repo.is_empty() || ref_name.is_empty() {
11734 return String::new();
11735 }
11736 let base = repo
11737 .trim_end_matches('/')
11738 .trim_end_matches(".git")
11739 .rsplit('/')
11740 .next()
11741 .unwrap_or("repo");
11742 let ref_safe: String = ref_name
11743 .chars()
11744 .map(|c| {
11745 if c.is_alphanumeric() || c == '-' || c == '.' {
11746 c
11747 } else {
11748 '_'
11749 }
11750 })
11751 .collect();
11752 format!("{base}_at_{ref_safe}_sloc")
11753}
11754
11755fn desktop_dir() -> PathBuf {
11757 if let Ok(profile) = std::env::var("USERPROFILE") {
11758 let p = PathBuf::from(profile).join("Desktop");
11759 if p.exists() {
11760 return p;
11761 }
11762 }
11763 if let Ok(home) = std::env::var("HOME") {
11764 let p = PathBuf::from(home).join("Desktop");
11765 if p.exists() {
11766 return p;
11767 }
11768 }
11769 workspace_root().join("out").join("web")
11770}
11771
11772fn resolve_input_path(raw: &str) -> PathBuf {
11773 let trimmed = raw.trim();
11774 if trimmed.is_empty() {
11775 return workspace_root().join("samples").join("basic");
11776 }
11777
11778 let candidate = PathBuf::from(trimmed);
11779 let resolved = if candidate.is_absolute() {
11780 candidate
11781 } else {
11782 let rooted = workspace_root().join(&candidate);
11783 if rooted.exists() {
11784 rooted
11785 } else {
11786 workspace_root().join(candidate)
11787 }
11788 };
11789
11790 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
11793 PathBuf::from(display_path(&canonical))
11794}
11795
11796fn dir_size_bytes(path: &Path) -> u64 {
11797 let mut total = 0u64;
11798 if let Ok(rd) = fs::read_dir(path) {
11799 for entry in rd.filter_map(Result::ok) {
11800 let p = entry.path();
11801 if p.is_file() {
11802 if let Ok(meta) = p.metadata() {
11803 total += meta.len();
11804 }
11805 } else if p.is_dir() {
11806 total += dir_size_bytes(&p);
11807 }
11808 }
11809 }
11810 total
11811}
11812
11813#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
11815 if bytes >= 1_073_741_824 {
11816 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
11817 } else if bytes >= 1_048_576 {
11818 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
11819 } else if bytes >= 1_024 {
11820 format!("{:.0} KB", bytes as f64 / 1_024.0)
11821 } else {
11822 format!("{bytes} B")
11823 }
11824}
11825
11826fn render_submodule_chips(
11827 root: &Path,
11828 submodules: &[(String, std::path::PathBuf)],
11829 out: &mut String,
11830) {
11831 use std::fmt::Write as _;
11832 let count = submodules.len();
11833 out.push_str(r#"<div class="submodule-preview-strip">"#);
11834 write!(
11835 out,
11836 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>"#,
11837 if count == 1 { "" } else { "s" }
11838 )
11839 .ok();
11840 out.push_str(r#"<div class="submodule-preview-chips">"#);
11841 for (sub_name, sub_rel_path) in submodules {
11842 let sub_abs = root.join(sub_rel_path);
11843 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
11844 let mut sub_stats = PreviewStats::default();
11845 let mut sub_rows: Vec<PreviewRow> = Vec::new();
11846 let mut sub_langs: Vec<&'static str> = Vec::new();
11847 let mut sub_budget = PreviewBudget {
11848 shown: 0,
11849 max_entries: 2000,
11850 max_depth: 9,
11851 };
11852 let mut sub_next_id = 1usize;
11853 let _ = collect_preview_rows(
11854 &sub_abs,
11855 &sub_abs,
11856 0,
11857 None,
11858 &mut sub_next_id,
11859 &mut sub_budget,
11860 &mut sub_stats,
11861 &mut sub_rows,
11862 &mut sub_langs,
11863 &[],
11864 &[],
11865 );
11866 let stats_json = format!(
11867 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
11868 sub_stats.directories,
11869 sub_stats.files,
11870 sub_stats.supported,
11871 sub_stats.skipped,
11872 sub_stats.unsupported
11873 );
11874 write!(
11875 out,
11876 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>"#,
11877 escape_html(sub_name),
11878 escape_html(&sub_rel_path.to_string_lossy()),
11879 escape_html(&sub_size),
11880 escape_html(&stats_json),
11881 escape_html(sub_name),
11882 escape_html(&sub_size),
11883 )
11884 .ok();
11885 }
11886 out.push_str(
11887 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
11888 );
11889 out.push_str(r"</div>");
11890}
11891
11892fn render_language_pills_row(languages: &[&str], out: &mut String) {
11893 use std::fmt::Write as _;
11894 if languages.is_empty() {
11895 out.push_str(
11896 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
11897 );
11898 return;
11899 }
11900 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
11901 for language in languages {
11902 if let Some(icon) = language_icon_file(language) {
11903 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();
11904 } else if let Some(svg) = language_inline_svg(language) {
11905 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();
11906 } else {
11907 write!(
11908 out,
11909 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
11910 escape_html(&language.to_ascii_lowercase()),
11911 escape_html(language)
11912 )
11913 .ok();
11914 }
11915 }
11916}
11917
11918#[allow(clippy::too_many_lines)]
11919fn build_preview_html(
11920 root: &Path,
11921 include_patterns: &[String],
11922 exclude_patterns: &[String],
11923) -> Result<String> {
11924 if !root.exists() {
11925 return Ok(format!(
11926 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
11927 escape_html(&display_path(root))
11928 ));
11929 }
11930
11931 let _selected = display_path(root);
11932 let mut stats = PreviewStats::default();
11933 let mut rows = Vec::new();
11934 let mut languages = Vec::new();
11935 let mut budget = PreviewBudget {
11936 shown: 0,
11937 max_entries: 600,
11938 max_depth: 9,
11939 };
11940 let mut next_row_id = 1usize;
11941
11942 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
11943 || root.to_string_lossy().into_owned(),
11944 std::string::ToString::to_string,
11945 );
11946 let root_modified = root
11947 .metadata()
11948 .ok()
11949 .and_then(|meta| meta.modified().ok())
11950 .map_or_else(|| "-".to_string(), format_system_time);
11951
11952 rows.push(PreviewRow {
11953 row_id: 0,
11954 parent_row_id: None,
11955 depth: 0,
11956 name: format!("{root_name}/"),
11957 kind: PreviewKind::Dir,
11958 is_dir: true,
11959 language: None,
11960 modified: root_modified,
11961 type_label: "Directory".to_string(),
11962 });
11963 collect_preview_rows(
11964 root,
11965 root,
11966 0,
11967 Some(0),
11968 &mut next_row_id,
11969 &mut budget,
11970 &mut stats,
11971 &mut rows,
11972 &mut languages,
11973 include_patterns,
11974 exclude_patterns,
11975 )?;
11976
11977 let root_size = format_dir_size(dir_size_bytes(root));
11978
11979 let mut out = String::new();
11980 write!(
11981 out,
11982 r#"<div class="explorer-wrap" data-project-size="{}">"#,
11983 escape_html(&root_size)
11984 )
11985 .ok();
11986 out.push_str(r#"<div class="explorer-toolbar compact">"#);
11987 out.push_str(r#"<div class="explorer-title-group">"#);
11988 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
11989 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
11990 out.push_str(r"</div></div>");
11991
11992 out.push_str(r#"<div class="scope-stats">"#);
11993 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();
11994 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();
11995 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();
11996 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();
11997 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();
11998 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>"#);
11999 out.push_str(r"</div>");
12000
12001 let submodules = sloc_core::detect_submodules(root);
12002 if !submodules.is_empty() {
12003 render_submodule_chips(root, &submodules, &mut out);
12004 }
12005
12006 out.push_str(r#"<div class="scope-info-row">"#);
12007 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
12008 render_language_pills_row(&languages, &mut out);
12009 out.push_str(r"</div></div>");
12010 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>"#);
12011 out.push_str(r"</div>");
12012
12013 out.push_str(r#"<div class="file-explorer-shell">"#);
12014 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>"#);
12015 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>"#);
12016 out.push_str(r#"<div class="file-explorer-tree">"#);
12017 for row in rows {
12018 let status_label = row.kind.label();
12019 let lang_attr = row.language.unwrap_or("");
12020 let toggle_html = if row.is_dir {
12021 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
12022 .to_string()
12023 } else {
12024 r#"<span class="tree-bullet">•</span>"#.to_string()
12025 };
12026 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();
12027 }
12028 if budget.shown >= budget.max_entries {
12029 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>"#);
12030 }
12031 out.push_str(r"</div></div></div>");
12032
12033 Ok(out)
12034}
12035
12036#[derive(Default)]
12037struct PreviewStats {
12038 directories: usize,
12039 files: usize,
12040 supported: usize,
12041 skipped: usize,
12042 unsupported: usize,
12043}
12044
12045struct PreviewRow {
12046 row_id: usize,
12047 parent_row_id: Option<usize>,
12048 depth: usize,
12049 name: String,
12050 kind: PreviewKind,
12051 is_dir: bool,
12052 language: Option<&'static str>,
12053 modified: String,
12054 type_label: String,
12055}
12056
12057#[derive(Copy, Clone)]
12058enum PreviewKind {
12059 Dir,
12060 Supported,
12061 Skipped,
12062 Unsupported,
12063}
12064
12065impl PreviewKind {
12066 const fn filter_key(self) -> &'static str {
12067 match self {
12068 Self::Dir => "dir",
12069 Self::Supported => "supported",
12070 Self::Skipped => "skipped",
12071 Self::Unsupported => "unsupported",
12072 }
12073 }
12074
12075 const fn label(self) -> &'static str {
12076 match self {
12077 Self::Dir => "dir",
12078 Self::Supported => "supported",
12079 Self::Skipped => "skipped by policy",
12080 Self::Unsupported => "unsupported",
12081 }
12082 }
12083
12084 const fn badge_class(self) -> &'static str {
12085 match self {
12086 Self::Dir => "badge badge-dir",
12087 Self::Supported => "badge badge-scan",
12088 Self::Skipped => "badge badge-skip",
12089 Self::Unsupported => "badge badge-unsupported",
12090 }
12091 }
12092
12093 const fn node_class(self) -> &'static str {
12094 match self {
12095 Self::Dir => "tree-node-dir",
12096 Self::Supported => "tree-node-supported",
12097 Self::Skipped => "tree-node-skipped",
12098 Self::Unsupported => "tree-node-unsupported",
12099 }
12100 }
12101}
12102
12103struct PreviewBudget {
12104 shown: usize,
12105 max_entries: usize,
12106 max_depth: usize,
12107}
12108
12109#[allow(clippy::too_many_arguments)]
12112fn handle_preview_dir_entry(
12113 root: &Path,
12114 path: &Path,
12115 name: &str,
12116 modified: String,
12117 depth: usize,
12118 parent_row_id: Option<usize>,
12119 row_id: usize,
12120 next_row_id: &mut usize,
12121 budget: &mut PreviewBudget,
12122 stats: &mut PreviewStats,
12123 rows: &mut Vec<PreviewRow>,
12124 languages: &mut Vec<&'static str>,
12125 include_patterns: &[String],
12126 exclude_patterns: &[String],
12127) -> Result<()> {
12128 let relative = preview_relative_path(root, path);
12129 if should_skip_preview_directory(&relative, exclude_patterns) {
12130 return Ok(());
12131 }
12132 stats.directories += 1;
12133 rows.push(PreviewRow {
12134 row_id,
12135 parent_row_id,
12136 depth: depth + 1,
12137 name: format!("{name}/"),
12138 kind: PreviewKind::Dir,
12139 is_dir: true,
12140 language: None,
12141 modified,
12142 type_label: "Directory".to_string(),
12143 });
12144 budget.shown += 1;
12145 if !matches!(name, ".git" | "node_modules" | "target") {
12146 collect_preview_rows(
12147 root,
12148 path,
12149 depth + 1,
12150 Some(row_id),
12151 next_row_id,
12152 budget,
12153 stats,
12154 rows,
12155 languages,
12156 include_patterns,
12157 exclude_patterns,
12158 )?;
12159 }
12160 Ok(())
12161}
12162
12163#[allow(clippy::too_many_arguments)]
12165fn handle_preview_file_entry(
12166 root: &Path,
12167 path: &Path,
12168 name: &str,
12169 modified: String,
12170 depth: usize,
12171 parent_row_id: Option<usize>,
12172 row_id: usize,
12173 budget: &mut PreviewBudget,
12174 stats: &mut PreviewStats,
12175 rows: &mut Vec<PreviewRow>,
12176 languages: &mut Vec<&'static str>,
12177 include_patterns: &[String],
12178 exclude_patterns: &[String],
12179) {
12180 let relative = preview_relative_path(root, path);
12181 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
12182 return;
12183 }
12184 stats.files += 1;
12185 let kind = classify_preview_file(name);
12186 match kind {
12187 PreviewKind::Supported => stats.supported += 1,
12188 PreviewKind::Skipped => stats.skipped += 1,
12189 PreviewKind::Unsupported => stats.unsupported += 1,
12190 PreviewKind::Dir => {}
12191 }
12192 let language = detect_language_name(name);
12193 if let Some(lang) = language {
12194 if !languages.contains(&lang) {
12195 languages.push(lang);
12196 }
12197 }
12198 rows.push(PreviewRow {
12199 row_id,
12200 parent_row_id,
12201 depth: depth + 1,
12202 name: name.to_owned(),
12203 kind,
12204 is_dir: false,
12205 language,
12206 modified,
12207 type_label: preview_type_label(name, language, kind),
12208 });
12209 budget.shown += 1;
12210}
12211
12212#[allow(clippy::too_many_arguments)]
12213#[allow(clippy::too_many_lines)]
12214fn collect_preview_rows(
12215 root: &Path,
12216 dir: &Path,
12217 depth: usize,
12218 parent_row_id: Option<usize>,
12219 next_row_id: &mut usize,
12220 budget: &mut PreviewBudget,
12221 stats: &mut PreviewStats,
12222 rows: &mut Vec<PreviewRow>,
12223 languages: &mut Vec<&'static str>,
12224 include_patterns: &[String],
12225 exclude_patterns: &[String],
12226) -> Result<()> {
12227 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
12228 return Ok(());
12229 }
12230
12231 let mut entries = fs::read_dir(dir)
12232 .with_context(|| format!("failed to read directory {}", dir.display()))?
12233 .filter_map(std::result::Result::ok)
12234 .collect::<Vec<_>>();
12235 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
12236
12237 for entry in entries {
12238 if budget.shown >= budget.max_entries {
12239 break;
12240 }
12241
12242 let path = entry.path();
12243 let name = entry.file_name().to_string_lossy().into_owned();
12244 let Ok(metadata) = entry.metadata() else {
12245 continue;
12246 };
12247 let row_id = *next_row_id;
12248 *next_row_id += 1;
12249 let modified = metadata
12250 .modified()
12251 .ok()
12252 .map_or_else(|| "-".to_string(), format_system_time);
12253
12254 if metadata.is_dir() {
12255 handle_preview_dir_entry(
12256 root,
12257 &path,
12258 &name,
12259 modified,
12260 depth,
12261 parent_row_id,
12262 row_id,
12263 next_row_id,
12264 budget,
12265 stats,
12266 rows,
12267 languages,
12268 include_patterns,
12269 exclude_patterns,
12270 )?;
12271 continue;
12272 }
12273
12274 if metadata.is_file() {
12275 handle_preview_file_entry(
12276 root,
12277 &path,
12278 &name,
12279 modified,
12280 depth,
12281 parent_row_id,
12282 row_id,
12283 budget,
12284 stats,
12285 rows,
12286 languages,
12287 include_patterns,
12288 exclude_patterns,
12289 );
12290 }
12291 }
12292
12293 Ok(())
12294}
12295
12296fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
12297 if let Some(language) = language {
12298 return format!("{language} source");
12299 }
12300 let lower = name.to_ascii_lowercase();
12301 let ext = Path::new(&lower)
12302 .extension()
12303 .and_then(|e| e.to_str())
12304 .unwrap_or("");
12305 match kind {
12306 PreviewKind::Skipped => {
12307 if lower.ends_with(".min.js") {
12308 "Minified asset".to_string()
12309 } else if [
12310 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
12311 ]
12312 .contains(&ext)
12313 {
12314 "Binary or archive".to_string()
12315 } else {
12316 "Skipped file".to_string()
12317 }
12318 }
12319 PreviewKind::Unsupported => {
12320 if ext.is_empty() {
12321 "Unsupported file".to_string()
12322 } else {
12323 format!("{} file", ext.to_ascii_uppercase())
12324 }
12325 }
12326 PreviewKind::Supported => "Supported source".to_string(),
12327 PreviewKind::Dir => "Directory".to_string(),
12328 }
12329}
12330
12331fn format_system_time(time: SystemTime) -> String {
12332 #[allow(clippy::cast_possible_wrap)]
12333 let secs = match time.duration_since(UNIX_EPOCH) {
12334 Ok(duration) => duration.as_secs() as i64,
12335 Err(_) => return "-".to_string(),
12336 };
12337 let days = secs.div_euclid(86_400);
12338 let secs_of_day = secs.rem_euclid(86_400);
12339 let (year, month, day) = civil_from_days(days);
12340 let hour = secs_of_day / 3_600;
12341 let minute = (secs_of_day % 3_600) / 60;
12342 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
12343}
12344
12345#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
12346fn civil_from_days(days: i64) -> (i32, u32, u32) {
12347 let z = days + 719_468;
12348 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
12349 let doe = z - era * 146_097;
12350 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
12351 let y = yoe + era * 400;
12352 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
12353 let mp = (5 * doy + 2) / 153;
12354 let d = doy - (153 * mp + 2) / 5 + 1;
12355 let m = mp + if mp < 10 { 3 } else { -9 };
12356 let year = y + i64::from(m <= 2);
12357 (year as i32, m as u32, d as u32)
12358}
12359
12360#[allow(clippy::case_sensitive_file_extension_comparisons)]
12363fn detect_language_name(name: &str) -> Option<&'static str> {
12364 let lower = name.to_ascii_lowercase();
12365 if lower.ends_with(".c") || lower.ends_with(".h") {
12366 Some("C")
12367 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
12368 .iter()
12369 .any(|s| lower.ends_with(s))
12370 {
12371 Some("C++")
12372 } else if lower.ends_with(".cs") {
12373 Some("C#")
12374 } else if lower.ends_with(".py") {
12375 Some("Python")
12376 } else if lower.ends_with(".sh") {
12377 Some("Shell")
12378 } else if [".ps1", ".psm1", ".psd1"]
12379 .iter()
12380 .any(|s| lower.ends_with(s))
12381 {
12382 Some("PowerShell")
12383 } else {
12384 None
12385 }
12386}
12387
12388fn language_icon_file(language: &str) -> Option<&'static str> {
12389 match language {
12390 "C" => Some("c.png"),
12391 "C++" => Some("cpp.png"),
12392 "C#" => Some("c-sharp.png"),
12393 "Python" => Some("python.png"),
12394 "Shell" => Some("shell.png"),
12395 "PowerShell" => Some("powershell.png"),
12396 "JavaScript" => Some("java-script.png"),
12397 "HTML" => Some("html-5.png"),
12398 "Java" => Some("java.png"),
12399 "Visual Basic" => Some("visual-basic.png"),
12400 "Assembly" => Some("asm.png"),
12401 "Go" => Some("go.png"),
12402 "R" => Some("r.png"),
12403 "XML" => Some("xml.png"),
12404 "Groovy" => Some("groovy.png"),
12405 "Dockerfile" => Some("docker.png"),
12406 "Makefile" => Some("makefile.svg"),
12407 "Perl" => Some("perl.svg"),
12408 _ => None,
12409 }
12410}
12411
12412fn language_inline_svg(language: &str) -> Option<&'static str> {
12417 match language {
12418 "Rust" => Some(
12419 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>"##,
12420 ),
12421 "TypeScript" => Some(
12422 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>"##,
12423 ),
12424 _ => None,
12425 }
12426}
12427
12428#[allow(clippy::case_sensitive_file_extension_comparisons)]
12431fn classify_preview_file(name: &str) -> PreviewKind {
12432 let lower = name.to_ascii_lowercase();
12433
12434 let scannable = [
12435 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
12436 ".psm1", ".psd1",
12437 ]
12438 .iter()
12439 .any(|suffix| lower.ends_with(suffix));
12440
12441 if scannable {
12442 PreviewKind::Supported
12443 } else if lower.ends_with(".min.js")
12444 || lower.ends_with(".lock")
12445 || lower.ends_with(".png")
12446 || lower.ends_with(".jpg")
12447 || lower.ends_with(".jpeg")
12448 || lower.ends_with(".gif")
12449 || lower.ends_with(".zip")
12450 || lower.ends_with(".pdf")
12451 || lower.ends_with(".pyc")
12452 || lower.ends_with(".xz")
12453 || lower.ends_with(".tar")
12454 || lower.ends_with(".gz")
12455 {
12456 PreviewKind::Skipped
12457 } else {
12458 PreviewKind::Unsupported
12459 }
12460}
12461
12462fn preview_relative_path(root: &Path, path: &Path) -> String {
12463 path.strip_prefix(root)
12464 .ok()
12465 .unwrap_or(path)
12466 .to_string_lossy()
12467 .replace('\\', "/")
12468 .trim_matches('/')
12469 .to_string()
12470}
12471
12472fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
12473 if relative.is_empty() {
12474 return false;
12475 }
12476
12477 exclude_patterns.iter().any(|pattern| {
12478 wildcard_match(pattern, relative)
12479 || wildcard_match(pattern, &format!("{relative}/"))
12480 || wildcard_match(pattern, &format!("{relative}/placeholder"))
12481 })
12482}
12483
12484fn should_include_preview_file(
12485 relative: &str,
12486 include_patterns: &[String],
12487 exclude_patterns: &[String],
12488) -> bool {
12489 if relative.is_empty() {
12490 return true;
12491 }
12492
12493 let included = include_patterns.is_empty()
12494 || include_patterns
12495 .iter()
12496 .any(|pattern| wildcard_match(pattern, relative));
12497 let excluded = exclude_patterns
12498 .iter()
12499 .any(|pattern| wildcard_match(pattern, relative));
12500
12501 included && !excluded
12502}
12503
12504fn wildcard_match(pattern: &str, candidate: &str) -> bool {
12505 let pattern = pattern.trim().replace('\\', "/");
12506 let candidate = candidate.trim().replace('\\', "/");
12507 let p = pattern.as_bytes();
12508 let c = candidate.as_bytes();
12509 let mut pi = 0usize;
12510 let mut ci = 0usize;
12511 let mut star: Option<usize> = None;
12512 let mut star_match = 0usize;
12513
12514 while ci < c.len() {
12515 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
12516 pi += 1;
12517 ci += 1;
12518 } else if pi < p.len() && p[pi] == b'*' {
12519 while pi < p.len() && p[pi] == b'*' {
12520 pi += 1;
12521 }
12522 star = Some(pi);
12523 star_match = ci;
12524 } else if let Some(star_pi) = star {
12525 star_match += 1;
12526 ci = star_match;
12527 pi = star_pi;
12528 } else {
12529 return false;
12530 }
12531 }
12532
12533 while pi < p.len() && p[pi] == b'*' {
12534 pi += 1;
12535 }
12536
12537 pi == p.len()
12538}
12539
12540fn escape_html(value: &str) -> String {
12541 value
12542 .replace('&', "&")
12543 .replace('<', "<")
12544 .replace('>', ">")
12545 .replace('"', """)
12546 .replace('\'', "'")
12547}
12548
12549#[derive(Clone)]
12550struct SubmoduleRow {
12551 name: String,
12552 relative_path: String,
12553 files_analyzed: u64,
12554 code_lines: u64,
12555 comment_lines: u64,
12556 blank_lines: u64,
12557 total_physical_lines: u64,
12558 html_url: Option<String>,
12559}
12560
12561#[derive(Template)]
12562#[template(
12563 source = r##"
12564<!doctype html>
12565<html lang="en">
12566<head>
12567 <meta charset="utf-8">
12568 <title>OxideSLOC | tmp-sloc</title>
12569 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12570 <style nonce="{{ csp_nonce }}">
12571 :root {
12572 --bg: #efe9e2;
12573 --surface: #fcfaf7;
12574 --surface-2: #f7f0e8;
12575 --surface-3: #efe3d5;
12576 --line: #dfcfbf;
12577 --line-strong: #cfb29c;
12578 --text: #2f241c;
12579 --muted: #6f6257;
12580 --muted-2: #917f71;
12581 --nav: #b85d33;
12582 --nav-2: #7a371b;
12583 --accent: #2563eb;
12584 --accent-2: #1d4ed8;
12585 --oxide: #b85d33;
12586 --oxide-2: #8f4220;
12587 --success-bg: #eaf9ee;
12588 --success-text: #1c8746;
12589 --warn-bg: #fff2d8;
12590 --warn-text: #926000;
12591 --danger-bg: #fdeaea;
12592 --danger-text: #b33b3b;
12593 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
12594 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
12595 --radius: 14px;
12596 }
12597
12598 body.dark-theme {
12599 --bg: #1b1511;
12600 --surface: #261c17;
12601 --surface-2: #2d221d;
12602 --surface-3: #372922;
12603 --line: #524238;
12604 --line-strong: #6c5649;
12605 --text: #f5ece6;
12606 --muted: #c7b7aa;
12607 --muted-2: #aa9485;
12608 --nav: #b85d33;
12609 --nav-2: #7a371b;
12610 --accent: #6f9bff;
12611 --accent-2: #4a78ee;
12612 --oxide: #d37a4c;
12613 --oxide-2: #b35428;
12614 --success-bg: #163927;
12615 --success-text: #8fe2a8;
12616 --warn-bg: #3c2d11;
12617 --warn-text: #f3cb75;
12618 --danger-bg: #3d1f1f;
12619 --danger-text: #ff9f9f;
12620 --shadow: 0 14px 28px rgba(0,0,0,0.28);
12621 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
12622 }
12623
12624 * { box-sizing: border-box; }
12625 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); }
12626 html { overflow-y: scroll; }
12627 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
12628 .top-nav, .page, .loading { position: relative; z-index: 2; }
12629 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
12630 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
12631 .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); }
12632 .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; }
12633 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
12634 .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)); }
12635 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
12636 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
12637 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
12638 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
12639 .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; }
12640 .nav-project-pill.visible { display:inline-flex; }
12641 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
12642 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
12643 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
12644 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12645 @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; } }
12646 .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; }
12647 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
12648 .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; }
12649 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
12650 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
12651 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
12652 .theme-toggle .icon-sun { display:none; }
12653 body.dark-theme .theme-toggle .icon-sun { display:block; }
12654 body.dark-theme .theme-toggle .icon-moon { display:none; }
12655 .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;}
12656 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12657 .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);}
12658 .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;}
12659 .settings-close:hover{color:var(--text);background:var(--surface-2);}
12660 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12661 .settings-modal-body{padding:14px 16px 16px;}
12662 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12663 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12664 .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;}
12665 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12666 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12667 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12668 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12669 .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;}
12670 .tz-select:focus{border-color:var(--oxide);}
12671 .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; }
12672 .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;}
12673 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
12674 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
12675 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
12676 .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; }
12677 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
12678 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
12679 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
12680 .wb-stats-header { padding: 10px 24px 0; }
12681 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
12682 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
12683 .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; }
12684 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12685 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12686 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12687 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
12688 .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; }
12689 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
12690 .ws-stat-analyzers { position: relative; }
12691 .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; }
12692 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
12693 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
12694 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
12695 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
12696 .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; }
12697 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
12698 .ws-divider { display: none; }
12699 .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%; }
12700 .ws-path-link:hover { color:var(--oxide); }
12701 body.dark-theme .ws-path-link { color:var(--oxide); }
12702 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
12703 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12704 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
12705 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12706 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
12707 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
12708 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
12709 .ws-mini-box-lg { flex:2 1 0; }
12710 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
12711 .ws-mini-box-br { flex:1.5 1 0; }
12712 .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); }
12713 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
12714 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
12715 #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; }
12716 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
12717 .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; }
12718 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
12719 .git-source-banner strong { font-weight:800; color:var(--text); }
12720 .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; }
12721 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
12722 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
12723 .git-source-banner a:hover { text-decoration:underline; }
12724 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
12725 .path-scope-sep { background:var(--line); margin:4px 14px; }
12726 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
12727 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
12728 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
12729 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
12730 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
12731 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
12732 .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; }
12733 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12734 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12735 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12736 .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; }
12737 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
12738 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
12739 [data-wb-tip] { cursor:help; }
12740 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
12741 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
12742 .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; }
12743 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
12744 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
12745 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
12746 .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; }
12747 .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); }
12748 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
12749 .side-info-card { padding: 18px; }
12750 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
12751 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
12752 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
12753 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
12754 .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); }
12755 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
12756 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
12757 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
12758 .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; }
12759 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
12760 .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; }
12761 .side-stack::-webkit-scrollbar { display: none; }
12762 .step-nav { padding: 20px 16px; }
12763 .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); }
12764 .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; }
12765 .step-button:hover { background: var(--surface-2); }
12766 .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); }
12767 .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; }
12768 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
12769 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
12770 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
12771 .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); }
12772 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
12773 .step-nav-sum-row:last-child { border-bottom:none; }
12774 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
12775 .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; }
12776 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
12777 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
12778 .quick-scan-section { padding: 10px 4px 14px; }
12779 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
12780 .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; }
12781 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
12782 .quick-scan-btn:active { transform:translateY(0); }
12783 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
12784 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
12785 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
12786 @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);} }
12787 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
12788 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
12789 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
12790 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
12791 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
12792 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
12793 .step-button.done .step-check { opacity:1; }
12794 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
12795 .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; }
12796 .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; }
12797 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
12798 .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; }
12799 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
12800 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
12801 .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; }
12802 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
12803 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
12804 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
12805 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
12806 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
12807 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
12808 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
12809 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
12810 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
12811 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
12812 .card-body { padding: 22px; }
12813 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
12814 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
12815 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
12816 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
12817 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
12818 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
12819 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
12820 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
12821 .field { min-width:0; }
12822 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
12823 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; }
12824 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); }
12825 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
12826 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); }
12827 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
12828 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
12829 .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; }
12830 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
12831 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
12832 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
12833 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
12834 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
12835 .input-group.compact { grid-template-columns: 1fr auto auto; }
12836 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
12837 .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)); }
12838 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
12839 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
12840 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
12841 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
12842 .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; }
12843 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
12844 .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; }
12845 .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); }
12846 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
12847 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
12848 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
12849 button.secondary { background: var(--surface); }
12850 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
12851 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
12852 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
12853 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
12854 .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); }
12855 .section + .wizard-actions { border-top: none; padding-top: 0; }
12856 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
12857 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
12858 .field-help-grid.coupled-help { margin-top: 12px; }
12859 .field-help-grid.preset-grid { align-items: start; }
12860 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
12861 .preset-inline-row .field { margin: 0; }
12862 .preset-inline-row .explainer-card { margin: 0; }
12863 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
12864 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
12865 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
12866 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
12867 .preset-kv-row > :last-child { flex:1; min-width:0; }
12868 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
12869 .output-field-row .field { margin: 0; }
12870 .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; }
12871 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
12872 .step3-subtitle { margin-bottom: 10px; max-width: none; }
12873 .counting-intro { margin-bottom: 8px; max-width: none; }
12874 .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; }
12875 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
12876 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
12877 .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; }
12878 .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; }
12879 .section-spacer-top { margin-top: 28px; }
12880 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
12881 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
12882 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
12883 .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); }
12884 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
12885 .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; }
12886 .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; }
12887 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
12888 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
12889 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
12890 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
12891 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
12892 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
12893 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
12894 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
12895 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
12896 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
12897 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
12898 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
12899 .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); }
12900 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
12901 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
12902 .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; }
12903 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
12904 .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; }
12905 .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; }
12906 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
12907 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
12908 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
12909 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
12910 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
12911 .advanced-rule-description strong { color: var(--text); }
12912 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
12913 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
12914 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
12915 .review-link:hover { text-decoration: underline; }
12916 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
12917 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
12918 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
12919 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
12920 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
12921 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
12922 .review-card ul { padding-left: 18px; margin: 0; }
12923 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
12924 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
12925 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
12926 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
12927 .review-card { min-height: 0; }
12928 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
12929 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
12930 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
12931 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
12932 .lang-overflow-chip { position:relative; cursor:default; }
12933 .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; }
12934 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
12935 .git-inline-row { align-items:start; }
12936 .mixed-line-card { display:flex; flex-direction:column; }
12937 .preset-inline-row .toggle-card { justify-content: center; }
12938 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
12939 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
12940 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
12941 .explorer-title { font-size: 18px; font-weight: 850; }
12942 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
12943 .explorer-subtitle.wide { max-width: none; }
12944 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
12945 .better-spacing { align-items:flex-start; justify-content:flex-end; }
12946 .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; }
12947 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
12948 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
12949 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
12950 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
12951 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
12952 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
12953 .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; }
12954 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
12955 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
12956 .scope-stat-button.supported { background: var(--success-bg); }
12957 .scope-stat-button.skipped { background: var(--warn-bg); }
12958 .scope-stat-button.unsupported { background: var(--danger-bg); }
12959 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
12960 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
12961 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
12962 [data-tooltip] { position: relative; }
12963 [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); }
12964 [data-tooltip]:hover::after { display: block; }
12965 .scope-stat-button[data-tooltip] { cursor: pointer; }
12966 .badge[data-tooltip] { cursor: help; }
12967 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
12968 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
12969 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
12970 .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; }
12971 .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; }
12972 code { display:inline-block; margin-top:0; padding:2px 7px; }
12973 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
12974 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
12975 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
12976 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
12977 .language-pill.muted-pill { color: var(--muted); }
12978 button.language-pill { appearance:none; cursor:pointer; }
12979 .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); }
12980 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
12981 .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; }
12982 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
12983 .file-explorer-search-row { margin-left: auto; }
12984 .explorer-filter-select { min-width: 170px; width: 170px; }
12985 .explorer-search { min-width: 300px; width: 300px; }
12986 .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); }
12987 .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; }
12988 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
12989 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
12990 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
12991 .file-explorer-tree { max-height: 640px; overflow:auto; }
12992 .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); }
12993 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
12994 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
12995 .tree-row.hidden-by-filter { display:none !important; }
12996 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
12997 .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; }
12998 .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; }
12999 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
13000 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
13001 .tree-node { display:inline-flex; align-items:center; min-width:0; }
13002 .tree-node-dir { color: var(--text); font-weight: 800; }
13003 .tree-node-supported { color: var(--success-text); }
13004 .tree-node-skipped { color: var(--warn-text); }
13005 .tree-node-unsupported { color: var(--danger-text); }
13006 .tree-node-more { color: var(--muted-2); font-style: italic; }
13007 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
13008 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
13009 .tree-status-cell { display:flex; justify-content:flex-start; }
13010 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
13011 .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; }
13012 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
13013 .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; }
13014 @keyframes prevSpin { to { transform:rotate(360deg); } }
13015 .preview-loading-text { flex:1; min-width:0; }
13016 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
13017 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
13018 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
13019 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
13020 .cov-scan-idle { display:none; }
13021 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
13022 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
13023 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
13024 .cov-scan-title { font-weight:600; font-size:12.5px; }
13025 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
13026 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
13027 .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; }
13028 .cov-scan-use:hover { opacity:.75; }
13029 .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; }
13030 .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; }
13031 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
13032 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
13033 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
13034 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
13035 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
13036 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
13037 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
13038 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
13039 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
13040 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
13041 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
13042 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
13043 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
13044 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
13045 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
13046 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
13047 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
13048 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
13049 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
13050 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
13051 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
13052 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
13053 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
13054 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
13055 .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); }
13056 .loading.active { display:flex; }
13057 .loading-card { width: min(730px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 48px rgba(0,0,0,0.22); padding: 36px 42px; }
13058 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
13059 .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; }
13060 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
13061 .lc-badge { display:inline-flex;align-items:center;gap:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.28);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:16px; }
13062 .lc-dot { width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:lcPulse 1.4s ease-in-out infinite;flex:0 0 auto; }
13063 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
13064 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
13065 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
13066 .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px 14px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:16px; }
13067 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
13068 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
13069 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
13070 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
13071 .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; }
13072 .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; }
13073 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
13074 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
13075 .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; }
13076 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
13077 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
13078 .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; }
13079 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
13080 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
13081 .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; }
13082 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
13083 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
13084 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
13085 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
13086 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
13087 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
13088 .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; }
13089 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
13090 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
13091 .hidden { display:none !important; }
13092 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13093 .site-footer a{color:var(--muted);}
13094 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
13095 @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; } }
13096 .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;}
13097 @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));}}
13098 .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;}
13099 .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; }
13100 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
13101 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
13102 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
13103 .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; }
13104 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
13105 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
13106 .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; }
13107 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
13108 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
13109 .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; }
13110 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
13111 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
13112 .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; }
13113 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
13114 .info-icon-btn:hover { color:var(--text); }
13115 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); }
13116 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
13117 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
13118 .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;}
13119 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
13120 .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;}
13121 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
13122 #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);}
13123 #offline-file-banner.show{display:flex;}
13124 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
13125 #offline-file-banner .ofb-text{flex:1;}
13126 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
13127 #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;}
13128 #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;}
13129 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
13130 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
13131 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
13132 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
13133 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
13134 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
13135 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
13136 </style>
13137</head>
13138<body id="page-top">
13139 <div id="offline-file-banner" role="alert">
13140 <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>
13141 <span class="ofb-text">
13142 Charts, images, and navigation require the oxide-sloc server.
13143 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
13144 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
13145 The metric tables below are fully readable without the server.
13146 </span>
13147 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
13148 </div>
13149 <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>
13150 <div class="background-watermarks" aria-hidden="true">
13151 <img src="/images/logo/logo-text.png" alt="" />
13152 <img src="/images/logo/logo-text.png" alt="" />
13153 <img src="/images/logo/logo-text.png" alt="" />
13154 <img src="/images/logo/logo-text.png" alt="" />
13155 <img src="/images/logo/logo-text.png" alt="" />
13156 <img src="/images/logo/logo-text.png" alt="" />
13157 <img src="/images/logo/logo-text.png" alt="" />
13158 <img src="/images/logo/logo-text.png" alt="" />
13159 <img src="/images/logo/logo-text.png" alt="" />
13160 <img src="/images/logo/logo-text.png" alt="" />
13161 <img src="/images/logo/logo-text.png" alt="" />
13162 <img src="/images/logo/logo-text.png" alt="" />
13163 <img src="/images/logo/logo-text.png" alt="" />
13164 <img src="/images/logo/logo-text.png" alt="" />
13165 </div>
13166 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13167 <div class="top-nav">
13168 <div class="top-nav-inner">
13169 <a class="brand" href="/">
13170 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
13171 <div class="brand-copy">
13172 <div class="brand-title">OxideSLOC</div>
13173 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
13174 </div>
13175 </a>
13176 <div class="nav-project-slot">
13177 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
13178 <span class="nav-project-label">Project</span>
13179 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
13180 </div>
13181 </div>
13182 <div class="nav-status">
13183 <a class="nav-pill" href="/">Home</a>
13184 <div class="nav-dropdown">
13185 <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>
13186 <div class="nav-dropdown-menu">
13187 <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>
13188 </div>
13189 </div>
13190 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13191 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13192 <div class="nav-dropdown">
13193 <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>
13194 <div class="nav-dropdown-menu">
13195 <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>
13196 </div>
13197 </div>
13198 <div class="server-status-wrap" id="server-status-wrap">
13199 <div class="nav-pill server-online-pill" id="server-status-pill">
13200 <span class="status-dot" id="status-dot"></span>
13201 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
13202 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13203 </div>
13204 <div class="server-status-tip">
13205 {% if server_mode %}
13206 OxideSLOC is running in server mode — accessible on your LAN.
13207 {% else %}
13208 OxideSLOC is running locally — only accessible from this machine.
13209 {% endif %}
13210 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13211 </div>
13212 </div>
13213 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13214 <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>
13215 </button>
13216 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13217 <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>
13218 <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>
13219 </button>
13220 </div>
13221 </div>
13222 </div>
13223
13224 <div class="loading" id="loading">
13225 <div class="loading-card">
13226 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
13227 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
13228 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
13229 <div class="lc-path" id="lc-path"></div>
13230 <div class="lc-metrics" id="lc-metrics">
13231 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
13232 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
13233 <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>
13234 </div>
13235 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
13236 <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>
13237 <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>
13238 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
13239 <div class="lc-actions hidden" id="lc-actions">
13240 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
13241 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
13242 </div>
13243 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
13244 <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>
13245 Cancel scan
13246 </button>
13247 </div>
13248 </div>
13249
13250 <div class="page">
13251 <div class="workbench-strip">
13252 <div class="workbench-box wb-stats">
13253 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
13254 <span class="wb-stats-title">Analysis session</span>
13255 </div>
13256 <div class="ws-left">
13257 <div class="ws-stat ws-stat-analyzers">
13258 <span class="ws-label">Analyzers</span>
13259 <span class="ws-value">
13260 <span class="ws-badge">41 languages</span>
13261 </span>
13262 <div class="ws-lang-tooltip">
13263 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
13264 <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>
13265 <div class="ws-lang-grid">
13266 <span class="ws-lang-item">Assembly</span>
13267 <span class="ws-lang-item">C</span>
13268 <span class="ws-lang-item">C++</span>
13269 <span class="ws-lang-item">C#</span>
13270 <span class="ws-lang-item">Clojure</span>
13271 <span class="ws-lang-item">CSS</span>
13272 <span class="ws-lang-item">Dart</span>
13273 <span class="ws-lang-item">Dockerfile</span>
13274 <span class="ws-lang-item">Elixir</span>
13275 <span class="ws-lang-item">Erlang</span>
13276 <span class="ws-lang-item">F#</span>
13277 <span class="ws-lang-item">Go</span>
13278 <span class="ws-lang-item">Groovy</span>
13279 <span class="ws-lang-item">Haskell</span>
13280 <span class="ws-lang-item">HTML</span>
13281 <span class="ws-lang-item">Java</span>
13282 <span class="ws-lang-item">JavaScript</span>
13283 <span class="ws-lang-item">Julia</span>
13284 <span class="ws-lang-item">Kotlin</span>
13285 <span class="ws-lang-item">Lua</span>
13286 <span class="ws-lang-item">Makefile</span>
13287 <span class="ws-lang-item">Nim</span>
13288 <span class="ws-lang-item">Obj-C</span>
13289 <span class="ws-lang-item">OCaml</span>
13290 <span class="ws-lang-item">Perl</span>
13291 <span class="ws-lang-item">PHP</span>
13292 <span class="ws-lang-item">PowerShell</span>
13293 <span class="ws-lang-item">Python</span>
13294 <span class="ws-lang-item">R</span>
13295 <span class="ws-lang-item">Ruby</span>
13296 <span class="ws-lang-item">Rust</span>
13297 <span class="ws-lang-item">Scala</span>
13298 <span class="ws-lang-item">SCSS</span>
13299 <span class="ws-lang-item">Shell</span>
13300 <span class="ws-lang-item">SQL</span>
13301 <span class="ws-lang-item">Svelte</span>
13302 <span class="ws-lang-item">Swift</span>
13303 <span class="ws-lang-item">TypeScript</span>
13304 <span class="ws-lang-item">Vue</span>
13305 <span class="ws-lang-item">XML</span>
13306 <span class="ws-lang-item">Zig</span>
13307 </div>
13308 </div>
13309 </div>
13310 <div class="ws-divider"></div>
13311 <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>
13312 <div class="ws-divider"></div>
13313 <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.">
13314 <span class="ws-label">Output</span>
13315 <span class="ws-value">
13316 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
13317 <span id="ws-output-root">project/sloc</span>
13318 </button>
13319 </span>
13320 </div>
13321 </div>
13322 </div>
13323 <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.">
13324 <div class="ws-history-label">Scan history</div>
13325 <div class="ws-history-inner">
13326 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
13327 <div class="ws-mini-label">Scans</div>
13328 <div class="ws-mini-value" id="ws-scan-count">—</div>
13329 </div>
13330 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
13331 <div class="ws-mini-label">Last Scan</div>
13332 <div class="ws-mini-value" id="ws-last-scan">—</div>
13333 </div>
13334 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
13335 <div class="ws-mini-label">Branch</div>
13336 <div class="ws-mini-value" id="ws-branch">—</div>
13337 </div>
13338 </div>
13339 </div>
13340 </div>
13341
13342 <div class="layout">
13343 <aside class="side-stack">
13344 <section class="step-nav">
13345 <h3>Guided scan setup</h3>
13346 <div class="sidebar-scroll-divider"></div>
13347 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
13348 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
13349 Top of page
13350 </a>
13351 <div class="sidebar-scroll-divider"></div>
13352 <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>
13353 <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>
13354 <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>
13355 <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>
13356
13357 <div class="step-steps-divider"></div>
13358
13359 <div class="step-nav-info" id="step-nav-info">
13360 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
13361 <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>
13362 </div>
13363
13364 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
13365 <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>
13366 <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>
13367 <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>
13368 </div>
13369
13370 <div class="quick-scan-divider"></div>
13371 <div class="quick-scan-section">
13372 <div class="quick-scan-label">No customization needed?</div>
13373 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
13374 <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>
13375 Quick Scan
13376 </button>
13377 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
13378 </div>
13379
13380 <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>
13381 <div class="sidebar-scroll-divider"></div>
13382 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
13383 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
13384 Skip to bottom
13385 </a>
13386 </section>
13387
13388 </aside>
13389
13390 <section class="card">
13391 <div class="card-header">
13392 <div class="card-title-row">
13393 <div>
13394 <h1 class="card-title">Guided scan configuration</h1>
13395 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
13396 </div>
13397 <div class="wizard-progress" aria-label="Scan setup progress">
13398 <div class="wizard-progress-top">
13399 <span class="wizard-progress-label">Setup progress</span>
13400 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
13401 </div>
13402 <div class="wizard-progress-track">
13403 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
13404 </div>
13405 </div>
13406 </div>
13407 </div>
13408 <div class="card-body">
13409 <form method="post" action="/analyze" id="analyze-form">
13410 <div class="wizard-step active" data-step="1">
13411 <div class="section">
13412 <div class="section-kicker">Step 1</div>
13413 <h2>Select project and preview scope</h2>
13414 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
13415 <div class="field">
13416 <label for="path">Project path</label>
13417 {% if !git_repo.is_empty() %}
13418 <div class="git-source-banner">
13419 <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>
13420 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
13421 <a href="/git-browser">← Back to Git Browser</a>
13422 </div>
13423 {% endif %}
13424 <div class="path-scope-grid">
13425 {% if !git_repo.is_empty() %}
13426 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
13427 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
13428 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
13429 {% else %}
13430 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
13431 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
13432 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
13433 {% endif %}
13434 <div class="path-scope-sep"></div>
13435 <div class="scope-legend-row">
13436 <span class="scope-legend-label">Scope legend:</span>
13437 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
13438 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
13439 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
13440 </div>
13441 </div>
13442 {% if git_repo.is_empty() %}
13443 {% if server_mode %}
13444 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
13445 ℹ️ Files are compressed and streamed — no fixed size limit.
13446 </div>
13447 {% endif %}
13448 <div class="path-info-row">
13449 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
13450 <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>
13451 <span id="project-size-text">Project size: —</span>
13452 </button>
13453 </div>
13454 {% else %}
13455 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
13456 {% endif %}
13457 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
13458 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
13459 </div>
13460
13461 <div class="scope-preview-divider" aria-hidden="true"></div>
13462
13463 <div id="preview-panel">
13464 <div class="preview-error">Loading preview...</div>
13465 </div>
13466 </div>
13467
13468 <div class="section" style="margin-top:14px;">
13469 <div class="preset-inline-row git-inline-row">
13470 <div class="toggle-card" style="margin:0;">
13471 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
13472 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
13473 <label class="checkbox">
13474 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
13475 <div>
13476 <span>Detect and separate git submodules</span>
13477 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
13478 </div>
13479 </label>
13480 </div>
13481 <div class="explainer-card prominent" style="margin:0;">
13482 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13483 <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>
13484 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
13485 path = libs/core
13486 url = https://github.com/org/core.git
13487
13488[submodule "libs/ui"]
13489 path = libs/ui
13490 url = https://github.com/org/ui.git</div>
13491 </div>
13492 </div>
13493 </div>
13494
13495 <div class="section">
13496 <div class="field-grid">
13497 <div class="field">
13498 <label for="include_globs">Include globs</label>
13499 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
13500 <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>
13501 </div>
13502 <div class="field">
13503 <label for="exclude_globs">Exclude globs</label>
13504 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
13505 <div id="quick-exclude-chips" class="quick-excl-row">
13506 <span class="quick-excl-label">Quick add:</span>
13507 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
13508 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
13509 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
13510 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
13511 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
13512 <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>
13513 </div>
13514 <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>
13515 </div>
13516 </div>
13517 <div class="glob-guidance-grid">
13518 <div class="glob-guidance-card">
13519 <strong>How to read them</strong>
13520 <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>
13521 </div>
13522 <div class="glob-guidance-card">
13523 <strong>Common include examples</strong>
13524 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
13525 </div>
13526 <div class="glob-guidance-card">
13527 <strong>Common exclude examples</strong>
13528 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
13529 </div>
13530 </div>
13531 </div>
13532
13533 <div class="section" style="margin-top:14px;">
13534 <div class="preset-inline-row git-inline-row">
13535 <div class="toggle-card" style="margin:0;">
13536 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
13537 <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>
13538 <div class="field" style="margin:0;">
13539 <div class="input-group compact">
13540 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
13541 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
13542 </div>
13543 <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>
13544 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
13545 </div>
13546 </div>
13547 <div class="explainer-card prominent" style="margin:0;">
13548 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13549 <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>
13550 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
13551lcov --capture --directory . --output-file coverage/lcov.info
13552
13553# C / C++ — llvm-cov (LCOV)
13554llvm-profdata merge -sparse default.profraw -o default.profdata
13555llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
13556
13557# C# — coverlet (Cobertura XML)
13558dotnet test --collect:"XPlat Code Coverage"
13559
13560# Python — pytest-cov (Cobertura XML)
13561pytest --cov --cov-report=xml
13562
13563# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
13564./gradlew jacocoTestReport</div>
13565 </div>
13566 </div>
13567 </div>
13568
13569 <div class="wizard-actions">
13570 <div class="left"></div>
13571 <div class="right">
13572 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
13573 </div>
13574 </div>
13575 </div>
13576
13577 <div class="wizard-step" data-step="2">
13578 <div class="section">
13579 <div class="section-kicker">Step 2</div>
13580 <h2>Choose counting behavior</h2>
13581 <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>
13582<div class="subsection-bar">Primary line classification</div>
13583 <div class="preset-kv-row">
13584 <div class="toggle-card mixed-line-card" style="margin:0;">
13585 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
13586 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
13587 <select id="mixed_line_policy" name="mixed_line_policy">
13588 <option value="code_only">Code only</option>
13589 <option value="code_and_comment">Code and comment</option>
13590 <option value="comment_only">Comment only</option>
13591 <option value="separate_mixed_category">Separate mixed category</option>
13592 </select>
13593 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
13594 </div>
13595 <div class="explainer-card prominent" style="margin:0;">
13596 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
13597 <div class="explainer-body" id="mixed-policy-description"></div>
13598 <div class="code-sample" id="mixed-policy-example"></div>
13599 </div>
13600 </div>
13601 </div>
13602
13603 <div class="subsection-bar">Additional scan rules</div>
13604 <div class="scan-rules-grid">
13605 <div class="preset-inline-row">
13606 <div class="toggle-card" style="margin:0;">
13607 <div class="field-help-title">Generated files</div>
13608 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
13609 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13610 </div>
13611 <div class="explainer-card prominent" style="margin:0;">
13612 <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>
13613 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
13614# Files matching codegen patterns are excluded:
13615# *.generated.cs *.pb.go *.g.dart</div>
13616 </div>
13617 </div>
13618 <div class="preset-inline-row">
13619 <div class="toggle-card" style="margin:0;">
13620 <div class="field-help-title">Minified files</div>
13621 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
13622 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13623 </div>
13624 <div class="explainer-card prominent" style="margin:0;">
13625 <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>
13626 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
13627# Heuristic: very long lines + low whitespace ratio
13628# jquery.min.js bundle.min.css → skipped</div>
13629 </div>
13630 </div>
13631 <div class="preset-inline-row">
13632 <div class="toggle-card" style="margin:0;">
13633 <div class="field-help-title">Vendor directories</div>
13634 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
13635 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13636 </div>
13637 <div class="explainer-card prominent" style="margin:0;">
13638 <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>
13639 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
13640# Directories named vendor/ node_modules/ third_party/
13641# → entire subtree is excluded from totals</div>
13642 </div>
13643 </div>
13644 <div class="preset-inline-row">
13645 <div class="toggle-card" style="margin:0;">
13646 <div class="field-help-title">Lockfiles and manifests</div>
13647 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
13648 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
13649 </div>
13650 <div class="explainer-card prominent" style="margin:0;">
13651 <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>
13652 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
13653# Files like package-lock.json Cargo.lock yarn.lock
13654# → skipped unless this is enabled</div>
13655 </div>
13656 </div>
13657 <div class="preset-inline-row">
13658 <div class="toggle-card" style="margin:0;">
13659 <div class="field-help-title">Binary handling</div>
13660 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
13661 <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>
13662 </div>
13663 <div class="explainer-card prominent" style="margin:0;">
13664 <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>
13665 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
13666# Detected via long lines + low whitespace heuristic
13667# .png .exe .so → skipped silently</div>
13668 </div>
13669 </div>
13670 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
13671 <div class="toggle-card" style="margin:0;">
13672 <div class="field-help-title">Python docstrings</div>
13673 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
13674 <label class="checkbox">
13675 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
13676 <span>Count as comment-style lines</span>
13677 </label>
13678 </div>
13679 <div class="explainer-card prominent" style="margin:0;">
13680 <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>
13681 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
13682 </div>
13683 </div>
13684 </div>
13685 <div class="subsection-bar">IEEE 1045-1992 counting</div>
13686 <div class="scan-rules-grid">
13687 <div class="preset-inline-row">
13688 <div class="toggle-card" style="margin:0;">
13689 <div class="field-help-title">Continuation lines</div>
13690 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
13691 <select name="continuation_line_policy" id="continuation_line_policy">
13692 <option value="each_physical_line" selected>Each physical line (default)</option>
13693 <option value="collapse_to_logical">Collapse to logical line</option>
13694 </select>
13695 </div>
13696 <div class="explainer-card prominent" style="margin:0;">
13697 <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>
13698 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
13699 ((a) > (b) ? (a) : (b))
13700# each_physical_line → 2 SLOC
13701# collapse_to_logical → 1 SLOC</div>
13702 </div>
13703 </div>
13704 <div class="preset-inline-row">
13705 <div class="toggle-card" style="margin:0;">
13706 <div class="field-help-title">Block-comment blanks</div>
13707 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
13708 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
13709 <option value="count_as_comment" selected>Count as comment (default)</option>
13710 <option value="count_as_blank">Count as blank</option>
13711 </select>
13712 </div>
13713 <div class="explainer-card prominent" style="margin:0;">
13714 <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>
13715 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
13716 * Summary line
13717 * ← blank inside block comment
13718 * Detail line
13719 */
13720# count_as_comment → blank counts toward comments
13721# count_as_blank → blank counts toward blanks</div>
13722 </div>
13723 </div>
13724 <div class="preset-inline-row">
13725 <div class="toggle-card" style="margin:0;">
13726 <div class="field-help-title">Compiler directives</div>
13727 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
13728 <select name="count_compiler_directives" id="count_compiler_directives">
13729 <option value="enabled" selected>Include in code SLOC (default)</option>
13730 <option value="disabled">Exclude from code SLOC</option>
13731 </select>
13732 </div>
13733 <div class="explainer-card prominent" style="margin:0;">
13734 <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>
13735 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
13736#define BUF 256 ← compiler directive
13737int main() { … } ← code
13738# enabled → 3 code SLOC
13739# disabled → 1 code SLOC + 2 directive lines</div>
13740 </div>
13741 </div>
13742 </div>
13743
13744 <div class="subsection-bar">Code Style Analysis</div>
13745 <div class="scan-rules-grid">
13746 <div class="preset-inline-row">
13747 <div class="toggle-card" style="margin:0;">
13748 <div class="field-help-title">Style analysis</div>
13749 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
13750 <select name="style_analysis_enabled" id="style_analysis_enabled">
13751 <option value="enabled" selected>Enabled (default)</option>
13752 <option value="disabled">Disabled — skip style scoring</option>
13753 </select>
13754 </div>
13755 <div class="explainer-card prominent" style="margin:0;">
13756 <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>
13757 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
13758# style_analysis_enabled = false (skip, faster scan)
13759# Disabling removes the Code Style section from the report.</div>
13760 </div>
13761 </div>
13762 <div class="preset-inline-row">
13763 <div class="toggle-card" style="margin:0;">
13764 <div class="field-help-title">Column-width threshold</div>
13765 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
13766 <select name="style_col_threshold" id="style_col_threshold">
13767 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
13768 <option value="100">100 columns (Uber Go, Google Java)</option>
13769 <option value="120">120 columns (Uber Go max, Kotlin)</option>
13770 </select>
13771 </div>
13772 <div class="explainer-card prominent" style="margin:0;">
13773 <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>
13774 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
13775# style_col_threshold = 100 (Uber Go, Google Java)
13776# style_col_threshold = 120 (Uber Go max, Kotlin)
13777# Files where <= 5% of lines exceed the limit
13778# are counted as "N-col compliant" in the report.</div>
13779 </div>
13780 </div>
13781 <div class="preset-inline-row">
13782 <div class="toggle-card" style="margin:0;">
13783 <div class="field-help-title">Score alert threshold</div>
13784 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
13785 <select name="style_score_threshold" id="style_score_threshold">
13786 <option value="0" selected>Off — no threshold (default)</option>
13787 <option value="40">40% — flag poorly styled files</option>
13788 <option value="50">50% — flag below-average files</option>
13789 <option value="60">60% — flag below-good files</option>
13790 <option value="70">70% — flag below-strong files</option>
13791 </select>
13792 </div>
13793 <div class="explainer-card prominent" style="margin:0;">
13794 <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>
13795 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
13796# style_score_threshold = 50 (flag files < 50%)
13797# Low-scoring files get a red left-border in the
13798# per-file style breakdown table.</div>
13799 </div>
13800 </div>
13801 </div>
13802
13803 <div class="always-tracked-tip">
13804 <div class="always-tracked-tip-icon">ℹ</div>
13805 <div class="always-tracked-tip-body">
13806 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
13807 <h4>Comment and blank-line basics & Lines on the boundary</h4>
13808 <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>
13809 </div>
13810 </div>
13811
13812 <div class="wizard-actions">
13813 <div class="left">
13814 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
13815 </div>
13816 <div class="right">
13817 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
13818 </div>
13819 </div>
13820 </div>
13821
13822 <div class="wizard-step" data-step="3">
13823 <div class="section">
13824 <div class="section-kicker">Step 3</div>
13825 <h2>Output and report identity</h2>
13826 <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>
13827 <div class="preset-kv-row">
13828 <div class="toggle-card" style="margin:0;">
13829 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
13830 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
13831 <select id="scan_preset">
13832 <option value="balanced">Balanced local scan</option>
13833 <option value="code_focused">Code focused</option>
13834 <option value="comment_audit">Comment audit</option>
13835 <option value="deep_review">Deep review</option>
13836 </select>
13837 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
13838 </div>
13839 <div class="explainer-card">
13840 <div class="field-help-title">Selected scan preset</div>
13841 <div class="explainer-body" id="scan-preset-description"></div>
13842 <div class="preset-summary-row" id="scan-preset-summary"></div>
13843 <div class="code-sample" id="scan-preset-example"></div>
13844 <div class="preset-note" id="scan-preset-note"></div>
13845 </div>
13846 </div>
13847 <hr class="step3-separator" />
13848 <div class="preset-kv-row">
13849 <div class="toggle-card" style="margin:0;">
13850 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
13851 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
13852 <select id="artifact_preset">
13853 <option value="review">Review bundle</option>
13854 <option value="full">Full bundle</option>
13855 <option value="html_only">HTML only</option>
13856 <option value="machine">Machine bundle</option>
13857 </select>
13858 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
13859 </div>
13860 <div class="explainer-card">
13861 <div class="field-help-title">Selected artifact preset</div>
13862 <div class="explainer-body" id="artifact-preset-description"></div>
13863 <div class="preset-summary-row" id="artifact-preset-summary"></div>
13864 <div class="code-sample" id="artifact-preset-example"></div>
13865 </div>
13866 </div>
13867 </div>
13868
13869 <div class="section section-spacer-top">
13870 <div class="output-field-row">
13871 <div class="field">
13872 <label for="output_dir">Output directory</label>
13873 {% if server_mode %}
13874 <div class="input-group compact">
13875 <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);" />
13876 </div>
13877 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
13878 {% else %}
13879 <div class="input-group compact">
13880 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
13881 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
13882 <button type="button" class="mini-button" id="use-default-output">Use default</button>
13883 </div>
13884 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
13885 {% endif %}
13886 </div>
13887 <div class="output-field-aside">
13888 <strong>Where reports land</strong>
13889 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.
13890 </div>
13891 </div>
13892 </div>
13893
13894 <div class="section section-spacer-top">
13895 <div class="output-field-row">
13896 <div class="field">
13897 <label for="report_title">Report title</label>
13898 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
13899 <div class="hint">Appears in HTML and PDF output headers.</div>
13900 </div>
13901 <div class="output-field-aside">
13902 <strong>Shown in exported artifacts</strong>
13903 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.
13904 </div>
13905 </div>
13906 </div>
13907
13908 <div class="section section-spacer-top">
13909 <div class="output-field-row">
13910 <div class="field">
13911 <label for="report_header_footer">Report header / footer</label>
13912 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
13913 <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>
13914 </div>
13915 <div class="output-field-aside">
13916 <strong>Page-level identification</strong>
13917 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.
13918 </div>
13919 </div>
13920 </div>
13921
13922 <div class="wizard-actions">
13923 <div class="left">
13924 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
13925 </div>
13926 <div class="right">
13927 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
13928 </div>
13929 </div>
13930 </div>
13931
13932 <div class="wizard-step" data-step="4">
13933 <div class="section">
13934 <div class="section-kicker">Step 4</div>
13935 <h2>Review selections and run</h2>
13936 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
13937 <div class="review-grid">
13938 <div class="review-card highlight">
13939 <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>
13940 <ul id="review-scan-summary"></ul>
13941 </div>
13942 <div class="review-card highlight">
13943 <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>
13944 <ul id="review-count-summary"></ul>
13945 </div>
13946 <div class="review-card">
13947 <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>
13948 <ul id="review-artifact-summary"></ul>
13949 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
13950 </div>
13951 <div class="review-card">
13952 <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>
13953 <ul id="review-preview-summary"></ul>
13954 </div>
13955 </div>
13956 </div>
13957
13958 <div class="wizard-actions">
13959 <div class="left">
13960 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
13961 </div>
13962 <div class="right">
13963 <button type="submit" id="submit-button" class="primary">Run analysis</button>
13964 </div>
13965 </div>
13966 </div>
13967 {% if server_mode %}
13968 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
13969 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
13970 {% endif %}
13971 </form>
13972 </div>
13973 </section>
13974 </div>
13975 </div>
13976
13977 <script nonce="{{ csp_nonce }}">
13978 (function () {
13979 function startScanPhase() {
13980 var phaseEl = document.getElementById("scan-phase");
13981 if (!phaseEl) return;
13982 var phases = [
13983 "Discovering files...",
13984 "Decoding file encodings...",
13985 "Detecting languages...",
13986 "Analyzing source lines...",
13987 "Applying counting policies...",
13988 "Aggregating results...",
13989 "Rendering report..."
13990 ];
13991 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
13992 var i = 0;
13993 function next() {
13994 phaseEl.style.opacity = "0";
13995 setTimeout(function () {
13996 phaseEl.textContent = phases[i];
13997 phaseEl.style.opacity = "0.85";
13998 var delay = durations[i] || 1800;
13999 i++;
14000 if (i < phases.length) { setTimeout(next, delay); }
14001 }, 200);
14002 }
14003 next();
14004 }
14005
14006 var form = document.getElementById("analyze-form");
14007 var loading = document.getElementById("loading");
14008 var submitButton = document.getElementById("submit-button");
14009 var pathInput = document.getElementById("path");
14010 var GIT_MODE = !!(pathInput && pathInput.readOnly);
14011 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
14012 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
14013 var outputDirInput = document.getElementById("output_dir");
14014 var reportTitleInput = document.getElementById("report_title");
14015 var previewPanel = document.getElementById("preview-panel");
14016 var refreshButton = document.getElementById("refresh-preview");
14017 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
14018 var useSamplePath = document.getElementById("use-sample-path");
14019 var useDefaultOutput = document.getElementById("use-default-output");
14020 var browsePath = document.getElementById("browse-path");
14021 var browseOutputDir = document.getElementById("browse-output-dir");
14022 var browseCoverage = document.getElementById("browse-coverage");
14023 var coverageInput = document.getElementById("coverage_file");
14024 var covScanStatus = document.getElementById("cov-scan-status");
14025 var coverageSuggestTimer = null;
14026 var covAutoFilled = false;
14027 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
14028 function fmtBytes(b) {
14029 b = Number(b) || 0;
14030 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
14031 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
14032 if (b >= 1024) return Math.round(b / 1024) + ' KB';
14033 return b + ' B';
14034 }
14035 var themeToggle = document.getElementById("theme-toggle");
14036
14037 function showBannerToast(msg, isError, opts) {
14038 opts = opts || {};
14039 var t = document.createElement('div');
14040 t.className = isError ? 'toast-error' : 'toast-success';
14041 var topPos = opts.top ? '80px' : null;
14042 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
14043 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
14044 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
14045 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
14046 if (opts.icon) {
14047 var inner = document.createElement('span');
14048 inner.innerHTML = opts.icon + ' ';
14049 t.appendChild(inner);
14050 }
14051 t.appendChild(document.createTextNode(msg));
14052 document.body.appendChild(t);
14053 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
14054 }
14055 var mixedLinePolicy = document.getElementById("mixed_line_policy");
14056 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
14057 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
14058 var scanPreset = document.getElementById("scan_preset");
14059 var artifactPreset = document.getElementById("artifact_preset");
14060 var includeGlobsInput = document.getElementById("include_globs");
14061 var excludeGlobsInput = document.getElementById("exclude_globs");
14062
14063 // Quick-exclude chips — append pattern to exclude_globs textarea.
14064 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
14065 chip.addEventListener("click", function() {
14066 var pattern = chip.getAttribute("data-pattern") || "";
14067 if (!pattern || !excludeGlobsInput) return;
14068 var current = excludeGlobsInput.value.trim();
14069 // For the "skip all" chip, replace any existing dep patterns cleanly.
14070 var patterns = pattern.split("\n");
14071 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
14072 var added = false;
14073 patterns.forEach(function(p) {
14074 p = p.trim();
14075 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
14076 });
14077 if (added) {
14078 excludeGlobsInput.value = lines.join("\n");
14079 excludeGlobsInput.dispatchEvent(new Event("input"));
14080 }
14081 chip.classList.add("active");
14082 });
14083 });
14084
14085 var liveReportTitle = document.getElementById("live-report-title");
14086 var navProjectPill = document.getElementById("nav-project-pill");
14087 var navProjectTitle = document.getElementById("nav-project-title");
14088 var reportTitlePreview = null;
14089 var wizardProgressFill = document.getElementById("wizard-progress-fill");
14090 var wizardProgressValue = document.getElementById("wizard-progress-value");
14091 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
14092 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
14093 var reportTitleTouched = false;
14094 var currentStep = 1;
14095 var previewTimer = null;
14096 var _previewGen = 0;
14097 var quickScanBtn = document.getElementById("quick-scan-btn");
14098
14099 function dismissAnalysisModal() {
14100 if (loading) loading.classList.remove("active");
14101 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14102 var el = document.getElementById(id);
14103 if (el) el.classList.add("hidden");
14104 });
14105 var cancelBtn = document.getElementById("lc-cancel-btn");
14106 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
14107 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
14108 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
14109 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14110 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14111 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14112 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14113 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14114 }
14115
14116 var lcDismissBtn = document.getElementById("lc-dismiss");
14117 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
14118
14119 function startAsyncAnalysis(formData) {
14120 var gitRepo = (formData.get("git_repo") || "").toString();
14121 var gitRef = (formData.get("git_ref") || "").toString();
14122 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
14123 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
14124
14125 var pathEl = document.getElementById("lc-path");
14126 if (pathEl) pathEl.textContent = displayPath;
14127
14128 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14129 var el = document.getElementById(id);
14130 if (el) el.classList.add("hidden");
14131 });
14132 var cancelBtn = document.getElementById("lc-cancel-btn");
14133 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
14134 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14135 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14136 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14137 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
14138 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
14139
14140 if (loading) loading.classList.add("active");
14141
14142 var startTime = Date.now();
14143 var elapsedTimer = setInterval(function() {
14144 var s = Math.floor((Date.now() - startTime) / 1000);
14145 var el = document.getElementById("lc-elapsed");
14146 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
14147 }, 1000);
14148
14149 var warnShown = false, pollRetries = 0, activeWaitId = null;
14150
14151 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();}
14152
14153 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
14154
14155 function lcShowCancelled() {
14156 clearInterval(elapsedTimer);
14157 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
14158 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
14159 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
14160 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
14161 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
14162 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
14163 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
14164 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
14165 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14166 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14167 }
14168
14169 var lcCancelBtn = document.getElementById("lc-cancel-btn");
14170 if (lcCancelBtn) {
14171 lcCancelBtn.onclick = function() {
14172 if (!activeWaitId) { dismissAnalysisModal(); return; }
14173 lcCancelBtn.disabled = true;
14174 lcCancelBtn.textContent = "Cancelling…";
14175 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
14176 .then(function() { lcShowCancelled(); })
14177 .catch(function() { lcShowCancelled(); });
14178 };
14179 }
14180
14181 function lcShowError(msg) {
14182 clearInterval(elapsedTimer);
14183 lcSetPhase("Failed");
14184 var msgEl = document.getElementById("lc-err-msg");
14185 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
14186 var errEl = document.getElementById("lc-err");
14187 var actEl = document.getElementById("lc-actions");
14188 if (errEl) errEl.classList.remove("hidden");
14189 if (actEl) actEl.classList.remove("hidden");
14190 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14191 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14192 }
14193
14194 function lcPoll(waitId) {
14195 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
14196 .then(function(r) {
14197 if (!r.ok) throw new Error("HTTP " + r.status);
14198 return r.json();
14199 })
14200 .then(function(data) {
14201 pollRetries = 0;
14202 if (data.state === "complete") {
14203 clearInterval(elapsedTimer);
14204 lcSetPhase("Done");
14205 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
14206 } else if (data.state === "failed") {
14207 lcShowError(data.message);
14208 } else if (data.state === "cancelled") {
14209 lcShowCancelled();
14210 } else {
14211 var s = Math.floor((Date.now() - startTime) / 1000);
14212 if (s > 90 && !warnShown) {
14213 warnShown = true;
14214 var w = document.getElementById("lc-warn");
14215 if (w) w.classList.remove("hidden");
14216 }
14217 lcSetPhase(data.phase || "Running");
14218 var fd = data.files_done || 0, ft = data.files_total || 0;
14219 if (ft > 0) {
14220 var card = document.getElementById("lc-files-card");
14221 if (card) card.classList.remove("hidden");
14222 var el = document.getElementById("lc-files");
14223 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
14224 }
14225 setTimeout(function() { lcPoll(waitId); }, 1500);
14226 }
14227 })
14228 .catch(function() {
14229 pollRetries++;
14230 if (pollRetries >= 5) {
14231 lcShowError("Lost connection to server. Reload to check status.");
14232 } else {
14233 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
14234 }
14235 });
14236 }
14237
14238 var params = new URLSearchParams(formData);
14239 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
14240 .then(function(r) {
14241 var waitId = r.headers.get("x-wait-id");
14242 if (!waitId) { window.location.href = "/scan"; return; }
14243 activeWaitId = waitId;
14244 setTimeout(function() { lcPoll(waitId); }, 1500);
14245 })
14246 .catch(function(err) {
14247 lcShowError("Could not reach server: " + (err.message || err));
14248 });
14249 }
14250
14251 if (quickScanBtn) {
14252 quickScanBtn.addEventListener("click", function () {
14253 var pathVal = pathInput ? pathInput.value.trim() : "";
14254 if (!pathVal) {
14255 alert("Please enter or browse to a project path first.");
14256 return;
14257 }
14258 quickScanBtn.disabled = true;
14259 quickScanBtn.textContent = "Scanning...";
14260 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
14261 startAsyncAnalysis(new FormData(form));
14262 });
14263 }
14264
14265 var mixedPolicyInfo = {
14266 code_only: {
14267 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.",
14268 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'
14269 },
14270 code_and_comment: {
14271 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.",
14272 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'
14273 },
14274 comment_only: {
14275 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.",
14276 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'
14277 },
14278 separate_mixed_category: {
14279 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.",
14280 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'
14281 }
14282 };
14283
14284 var scanPresetInfo = {
14285 balanced: {
14286 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.",
14287 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
14288 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
14289 note: "Best when you want a stable local overview before making deeper adjustments.",
14290 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14291 },
14292 code_focused: {
14293 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
14294 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
14295 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
14296 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
14297 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14298 },
14299 comment_audit: {
14300 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
14301 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
14302 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
14303 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
14304 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14305 },
14306 deep_review: {
14307 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
14308 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
14309 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
14310 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
14311 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
14312 }
14313 };
14314
14315 var artifactPresetInfo = {
14316 review: {
14317 description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
14318 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
14319 example: "Ideal for a quick local review before sharing results."
14320 },
14321 full: {
14322 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
14323 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
14324 example: "Use when producing a deliverable or storing a snapshot for future comparison."
14325 },
14326 html_only: {
14327 description: "Standalone HTML report only. No PDF generation, no data files.",
14328 chips: ["HTML only"],
14329 example: "Fastest option when you only need to open the report in a browser."
14330 },
14331 machine: {
14332 description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
14333 chips: ["JSON", "CSV", "no HTML", "no PDF"],
14334 example: "Use in CI to capture metrics without generating visual reports."
14335 }
14336 };
14337
14338 function applyArtifactPreset() {
14339 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
14340 if (!info) return;
14341 var descEl = document.getElementById("artifact-preset-description");
14342 var exampleEl = document.getElementById("artifact-preset-example");
14343 if (descEl) descEl.textContent = info.description;
14344 if (exampleEl) exampleEl.textContent = info.example;
14345 renderPresetChips("artifact-preset-summary", info.chips);
14346 }
14347
14348 function applyTheme(theme) {
14349 if (theme === "dark") document.body.classList.add("dark-theme");
14350 else document.body.classList.remove("dark-theme");
14351 }
14352
14353 function loadSavedTheme() {
14354 var saved = null;
14355 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
14356 applyTheme(saved === "dark" ? "dark" : "light");
14357 }
14358
14359 function updateScrollProgress() {
14360 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
14361 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
14362 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
14363 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
14364 var step = Math.min(Math.max(currentStep, 1), 4);
14365 var base = stepBase[step];
14366 var end = stepEnd[step];
14367
14368 var scrollFrac = 0;
14369 var activePanel = document.querySelector(".wizard-step.active");
14370 if (activePanel) {
14371 var scrollTop = window.scrollY || window.pageYOffset || 0;
14372 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
14373 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
14374 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
14375 var scrolled = scrollTop + viewH - panelTop;
14376 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
14377 }
14378
14379 var percent = Math.round(base + (end - base) * scrollFrac);
14380 percent = Math.min(end, Math.max(base, percent));
14381 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
14382 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
14383 }
14384
14385 function updateWizardProgress() {
14386 updateScrollProgress();
14387 }
14388
14389 var stepDescriptions = [
14390 "Choose a project folder, apply scope filters, and preview which files will be counted.",
14391 "Configure how mixed code-plus-comment lines and docstrings are classified.",
14392 "Pick your output formats, scan preset, and where reports are saved.",
14393 "Review all settings and launch the analysis."
14394 ];
14395
14396 function updateStepNav(step) {
14397 var infoLabel = document.getElementById("step-nav-info-label");
14398 var infoDesc = document.getElementById("step-nav-info-desc");
14399 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
14400 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
14401 }
14402
14403 function updateSidebarSummary() {
14404 var sumPath = document.getElementById("sum-path");
14405 var sumPreset = document.getElementById("sum-preset");
14406 var sumOutput = document.getElementById("sum-output");
14407 var sidebarSummary = document.getElementById("sidebar-summary");
14408 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
14409 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
14410 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
14411 if (sumPath) sumPath.textContent = pathVal || "—";
14412 if (sumPreset) sumPreset.textContent = presetVal || "—";
14413 if (sumOutput) sumOutput.textContent = outputVal || "—";
14414 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
14415 }
14416
14417 function setStep(step, pushHistory) {
14418 currentStep = step;
14419 stepPanels.forEach(function (panel) {
14420 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
14421 });
14422 stepButtons.forEach(function (button) {
14423 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
14424 });
14425 var layoutEl = document.querySelector(".layout");
14426 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
14427 updateWizardProgress();
14428 updateStepNav(step);
14429 stepButtons.forEach(function(btn) {
14430 var t = Number(btn.getAttribute("data-step-target"));
14431 btn.classList.toggle("done", t < step);
14432 });
14433 updateSidebarSummary();
14434
14435 if (pushHistory !== false) {
14436 try {
14437 history.pushState({ wizardStep: step }, "", "#step" + step);
14438 } catch (e) {}
14439 }
14440
14441 window.scrollTo({ top: 0, behavior: "instant" });
14442 }
14443
14444 window.addEventListener("popstate", function (e) {
14445 if (e.state && e.state.wizardStep) {
14446 setStep(e.state.wizardStep, false);
14447 } else {
14448 var hashMatch = location.hash.match(/^#step([1-4])$/);
14449 if (hashMatch) setStep(Number(hashMatch[1]), false);
14450 }
14451 });
14452
14453 function inferTitleFromPath(value) {
14454 if (!value) return "project";
14455 var cleaned = value.replace(/[\/\\]+$/, "");
14456 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
14457 return parts.length ? parts[parts.length - 1] : value;
14458 }
14459
14460 function updateReportTitleFromPath() {
14461 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
14462 if (!reportTitleTouched) {
14463 reportTitleInput.value = inferred;
14464 }
14465 var title = reportTitleInput.value || inferred;
14466 if (liveReportTitle) liveReportTitle.textContent = title;
14467 if (reportTitlePreview) reportTitlePreview.textContent = title;
14468 document.title = "OxideSLOC | " + title;
14469
14470 var projectPath = (pathInput.value || "").trim();
14471 if (navProjectPill && navProjectTitle) {
14472 if (projectPath.length > 0) {
14473 navProjectTitle.textContent = inferred;
14474 navProjectPill.classList.add("visible");
14475 } else {
14476 navProjectTitle.textContent = "";
14477 navProjectPill.classList.remove("visible");
14478 }
14479 }
14480 }
14481
14482 function updateMixedPolicyUI() {
14483 var key = mixedLinePolicy.value || "code_only";
14484 var info = mixedPolicyInfo[key];
14485 document.getElementById("mixed-policy-description").textContent = info.description;
14486 document.getElementById("mixed-policy-example").textContent = info.example;
14487 }
14488
14489 function updatePythonDocstringUI() {
14490 var checked = !!pythonDocstrings.checked;
14491 document.getElementById("python-docstring-example").textContent = checked
14492 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
14493 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
14494 document.getElementById("python-docstring-live-help").textContent = checked
14495 ? "Enabled: docstrings contribute to comment-style totals."
14496 : "Disabled: docstrings are not counted as comment content.";
14497 }
14498
14499 function renderPresetChips(targetId, chips) {
14500 var target = document.getElementById(targetId);
14501 if (!target) return;
14502 target.innerHTML = (chips || []).map(function (chip) {
14503 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
14504 }).join('');
14505 }
14506
14507 function updatePresetDescriptions() {
14508 var scanInfo = scanPresetInfo[scanPreset.value];
14509 if (!scanInfo) return;
14510 document.getElementById("scan-preset-description").textContent = scanInfo.description;
14511 document.getElementById("scan-preset-example").textContent = scanInfo.example;
14512 document.getElementById("scan-preset-note").textContent = scanInfo.note;
14513 renderPresetChips("scan-preset-summary", scanInfo.chips);
14514 }
14515
14516 function applyScanPreset() {
14517 var info = scanPresetInfo[scanPreset.value];
14518 if (!info || !info.apply) return;
14519 mixedLinePolicy.value = info.apply.mixed;
14520 pythonDocstrings.checked = !!info.apply.docstrings;
14521 document.getElementById("generated_file_detection").value = info.apply.generated;
14522 document.getElementById("minified_file_detection").value = info.apply.minified;
14523 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
14524 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
14525 document.getElementById("binary_file_behavior").value = info.apply.binary;
14526 updateMixedPolicyUI();
14527 updatePythonDocstringUI();
14528 }
14529
14530 function updateReview() {
14531 var scanSummary = document.getElementById("review-scan-summary");
14532 var countSummary = document.getElementById("review-count-summary");
14533 var artifactSummary = document.getElementById("review-artifact-summary");
14534 var outputSummary = document.getElementById("review-output-summary");
14535 var previewSummary = document.getElementById("review-preview-summary");
14536 var readinessSummary = document.getElementById("review-readiness-summary");
14537 var includeText = document.getElementById("include_globs").value.trim();
14538 var excludeText = document.getElementById("exclude_globs").value.trim();
14539 var sidePathPreview = document.getElementById("side-path-preview");
14540 var sideOutputPreview = document.getElementById("side-output-preview");
14541 var sideTitlePreview = document.getElementById("side-title-preview");
14542
14543 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
14544 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
14545 if (sideTitlePreview) {
14546 var rt = document.getElementById("report_title");
14547 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
14548 }
14549
14550 scanSummary.innerHTML = ""
14551 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
14552 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
14553 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
14554
14555 countSummary.innerHTML = ""
14556 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
14557 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
14558 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
14559 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
14560 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
14561 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
14562 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
14563 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
14564
14565 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
14566
14567 outputSummary.innerHTML = ""
14568 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
14569 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
14570
14571 if (previewSummary) {
14572 if (GIT_MODE) {
14573 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>';
14574 } else {
14575 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
14576 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
14577 var statMap = {};
14578 statButtons.forEach(function (button) {
14579 var valueNode = button.querySelector('.scope-stat-value');
14580 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
14581 });
14582 previewSummary.innerHTML = ''
14583 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
14584 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
14585 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
14586 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
14587 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
14588 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
14589
14590 if (readinessSummary) {
14591 readinessSummary.innerHTML = ''
14592 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
14593 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
14594 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
14595 }
14596 } // end else (non-GIT_MODE)
14597 }
14598 }
14599
14600 function escapeHtml(value) {
14601 return String(value)
14602 .replace(/&/g, "&")
14603 .replace(/</g, "<")
14604 .replace(/>/g, ">")
14605 .replace(/"/g, """)
14606 .replace(/'/g, "'");
14607 }
14608
14609 function isPythonVisible() {
14610 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
14611 }
14612
14613 function syncPythonVisibility() {
14614 var html = previewPanel.textContent || "";
14615 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
14616 pythonWraps.forEach(function (node) {
14617 node.classList.toggle("hidden", !hasPython);
14618 });
14619 }
14620
14621 function attachPreviewInteractions() {
14622 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
14623 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
14624 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
14625 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
14626 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
14627 var searchInput = previewPanel.querySelector("#explorer-search");
14628 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
14629 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
14630 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
14631 var activeFilter = "all";
14632 var activeLanguage = "";
14633 var searchTerm = "";
14634 var currentSortKey = null;
14635 var currentSortOrder = "asc";
14636 var childRows = {};
14637
14638 rows.forEach(function (row) {
14639 var parentId = row.getAttribute("data-parent-id") || "";
14640 var rowId = row.getAttribute("data-row-id") || "";
14641 if (!childRows[parentId]) childRows[parentId] = [];
14642 childRows[parentId].push(rowId);
14643 });
14644
14645 function rowById(id) {
14646 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
14647 }
14648
14649 function hasCollapsedAncestor(row) {
14650 var parentId = row.getAttribute("data-parent-id");
14651 while (parentId) {
14652 var parent = rowById(parentId);
14653 if (!parent) break;
14654 if (parent.getAttribute("data-expanded") === "false") return true;
14655 parentId = parent.getAttribute("data-parent-id");
14656 }
14657 return false;
14658 }
14659
14660 function updateToggleGlyph(row) {
14661 var toggle = row.querySelector(".tree-toggle");
14662 if (!toggle) return;
14663 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
14664 }
14665
14666 function rowSortValue(row, key) {
14667 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
14668 }
14669
14670 function updateSortButtons() {
14671 sortButtons.forEach(function (button) {
14672 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
14673 var indicator = button.querySelector(".tree-sort-indicator");
14674 button.classList.toggle("active", isActive);
14675 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
14676 if (indicator) {
14677 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
14678 }
14679 });
14680 }
14681
14682 function sortSiblingRows() {
14683 if (!treeContainer) {
14684 updateSortButtons();
14685 return;
14686 }
14687
14688 var rowMap = {};
14689 var childrenMap = {};
14690 rows.forEach(function (row) {
14691 var rowId = row.getAttribute("data-row-id");
14692 var parentId = row.getAttribute("data-parent-id") || "";
14693 rowMap[rowId] = row;
14694 if (!childrenMap[parentId]) childrenMap[parentId] = [];
14695 childrenMap[parentId].push(rowId);
14696 });
14697
14698 Object.keys(childrenMap).forEach(function (parentId) {
14699 if (!parentId) return;
14700 childrenMap[parentId].sort(function (a, b) {
14701 var rowA = rowMap[a];
14702 var rowB = rowMap[b];
14703 if (!currentSortKey) {
14704 return Number(a) - Number(b);
14705 }
14706 var valueA = rowSortValue(rowA, currentSortKey);
14707 var valueB = rowSortValue(rowB, currentSortKey);
14708 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
14709 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
14710 var fallbackA = rowSortValue(rowA, "name");
14711 var fallbackB = rowSortValue(rowB, "name");
14712 if (fallbackA < fallbackB) return -1;
14713 if (fallbackA > fallbackB) return 1;
14714 return Number(a) - Number(b);
14715 });
14716 });
14717
14718 var orderedIds = [];
14719 function pushChildren(parentId) {
14720 (childrenMap[parentId] || []).forEach(function (childId) {
14721 orderedIds.push(childId);
14722 pushChildren(childId);
14723 });
14724 }
14725
14726 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
14727 orderedIds.push(topId);
14728 pushChildren(topId);
14729 });
14730
14731 orderedIds.forEach(function (id) {
14732 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
14733 });
14734 updateSortButtons();
14735 }
14736
14737 function updateLanguageButtons() {
14738 languageButtons.forEach(function (button) {
14739 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
14740 var isActive = languageValue === activeLanguage;
14741 button.classList.toggle("active", isActive);
14742 });
14743 }
14744
14745 function rowSelfMatches(row) {
14746 var kind = row.getAttribute("data-kind");
14747 var status = row.getAttribute("data-status");
14748 var language = (row.getAttribute("data-language") || "").toLowerCase();
14749 var name = row.getAttribute("data-name-lower") || "";
14750 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
14751 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
14752 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
14753 var passesLanguage = !activeLanguage || language === activeLanguage;
14754 return passesFilter && passesSearch && passesLanguage;
14755 }
14756
14757 function hasMatchingDescendant(rowId) {
14758 return (childRows[rowId] || []).some(function (childId) {
14759 var childRow = rowById(childId);
14760 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
14761 });
14762 }
14763
14764 function rowMatches(row) {
14765 if (rowSelfMatches(row)) return true;
14766 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
14767 }
14768
14769 function resetViewState() {
14770 activeFilter = "all";
14771 activeLanguage = "";
14772 searchTerm = "";
14773 currentSortKey = null;
14774 currentSortOrder = "asc";
14775 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
14776 if (searchInput) searchInput.value = "";
14777 if (filterSelect) filterSelect.value = "all";
14778 updateLanguageButtons();
14779 }
14780
14781 function applyVisibility() {
14782 rows.forEach(function (row) {
14783 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
14784 row.classList.toggle("hidden-by-filter", !visible);
14785 row.style.display = visible ? "grid" : "none";
14786 });
14787 buttons.forEach(function (button) {
14788 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
14789 });
14790 if (filterSelect) filterSelect.value = activeFilter;
14791 }
14792
14793 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
14794 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
14795 var originalStats = {};
14796 buttons.forEach(function (btn) {
14797 var f = btn.getAttribute('data-filter');
14798 var v = btn.querySelector('.scope-stat-value');
14799 if (f && v) originalStats[f] = v.textContent;
14800 });
14801
14802 function applySubmoduleStats(statsJson) {
14803 try {
14804 var s = JSON.parse(statsJson);
14805 buttons.forEach(function (btn) {
14806 var f = btn.getAttribute('data-filter');
14807 var v = btn.querySelector('.scope-stat-value');
14808 if (!v) return;
14809 if (f === 'dir') v.textContent = s.dirs;
14810 else if (f === 'file') v.textContent = s.files;
14811 else if (f === 'supported') v.textContent = s.supported;
14812 else if (f === 'skipped') v.textContent = s.skipped;
14813 else if (f === 'unsupported') v.textContent = s.unsupported;
14814 });
14815 } catch (e) {}
14816 }
14817
14818 function restoreBaseRepoStats() {
14819 buttons.forEach(function (btn) {
14820 var f = btn.getAttribute('data-filter');
14821 var v = btn.querySelector('.scope-stat-value');
14822 if (v && originalStats[f]) v.textContent = originalStats[f];
14823 });
14824 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
14825 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
14826 }
14827
14828 submoduleChips.forEach(function (chip) {
14829 chip.addEventListener('click', function () {
14830 var statsJson = chip.getAttribute('data-sub-stats');
14831 if (!statsJson) return;
14832 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
14833 chip.classList.add('active');
14834 applySubmoduleStats(statsJson);
14835 if (baseRepoBtn) baseRepoBtn.style.display = '';
14836 });
14837 });
14838
14839 if (baseRepoBtn) {
14840 baseRepoBtn.addEventListener('click', function () {
14841 restoreBaseRepoStats();
14842 resetViewState();
14843 sortSiblingRows();
14844 applyVisibility();
14845 });
14846 }
14847
14848 buttons.forEach(function (button) {
14849 button.addEventListener("click", function () {
14850 var filterValue = button.getAttribute("data-filter") || "all";
14851 if (filterValue === "reset-view") {
14852 restoreBaseRepoStats();
14853 resetViewState();
14854 sortSiblingRows();
14855 applyVisibility();
14856 return;
14857 }
14858 activeFilter = filterValue;
14859 applyVisibility();
14860 });
14861 });
14862
14863 rows.forEach(function (row) {
14864 updateToggleGlyph(row);
14865 var toggle = row.querySelector(".tree-toggle");
14866 if (toggle) {
14867 toggle.addEventListener("click", function () {
14868 var expanded = row.getAttribute("data-expanded") !== "false";
14869 row.setAttribute("data-expanded", expanded ? "false" : "true");
14870 updateToggleGlyph(row);
14871 applyVisibility();
14872 });
14873 }
14874 });
14875
14876 actionButtons.forEach(function (button) {
14877 button.addEventListener("click", function () {
14878 var action = button.getAttribute("data-explorer-action");
14879 if (action === "expand-all") {
14880 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
14881 } else if (action === "collapse-all") {
14882 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
14883 } else if (action === "clear-filters") {
14884 resetViewState();
14885 }
14886 sortSiblingRows();
14887 applyVisibility();
14888 });
14889 });
14890
14891 if (filterSelect) {
14892 filterSelect.addEventListener("change", function () {
14893 activeFilter = filterSelect.value || "all";
14894 applyVisibility();
14895 });
14896 }
14897
14898 languageButtons.forEach(function (button) {
14899 button.addEventListener("click", function () {
14900 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
14901 updateLanguageButtons();
14902 applyVisibility();
14903 });
14904 });
14905
14906 sortButtons.forEach(function (button) {
14907 button.addEventListener("click", function () {
14908 var sortKey = button.getAttribute("data-sort-key");
14909 if (currentSortKey === sortKey) {
14910 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
14911 } else {
14912 currentSortKey = sortKey;
14913 currentSortOrder = "asc";
14914 }
14915 sortSiblingRows();
14916 applyVisibility();
14917 });
14918 });
14919
14920 if (searchInput) {
14921 searchInput.addEventListener("input", function () {
14922 searchTerm = searchInput.value.trim().toLowerCase();
14923 applyVisibility();
14924 });
14925 }
14926
14927 updateLanguageButtons();
14928 sortSiblingRows();
14929 applyVisibility();
14930 }
14931
14932 function loadPreview() {
14933 if (!previewPanel || !pathInput) return;
14934 if (GIT_MODE) {
14935 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>';
14936 return;
14937 }
14938 var path = pathInput.value.trim();
14939 var zeroWarn = document.getElementById('zero-files-warning');
14940 if (!path) {
14941 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
14942 if (zeroWarn) zeroWarn.style.display = 'none';
14943 return;
14944 }
14945 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
14946 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
14947 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
14948 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
14949 var myGen = ++_previewGen;
14950 var _prevMsgs = [
14951 'Scanning directory structure…',
14952 'Detecting file types…',
14953 'Applying include / exclude filters…',
14954 'Estimating file counts…',
14955 'Building scope preview…',
14956 'Almost there…'
14957 ];
14958 var _prevMsgIdx = 0;
14959 var _prevStart = Date.now();
14960 previewPanel.innerHTML =
14961 '<div class="preview-loading">' +
14962 '<div class="preview-spinner"></div>' +
14963 '<div class="preview-loading-text">' +
14964 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
14965 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
14966 '</div></div>';
14967 var _sizeTextEl = document.getElementById('project-size-text');
14968 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
14969 window._previewInterval = setInterval(function() {
14970 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
14971 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
14972 var ml = document.getElementById('plm');
14973 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
14974 }, 1500);
14975 window._previewElapsedTimer = setInterval(function() {
14976 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
14977 var el = document.getElementById('ple');
14978 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
14979 }, 1000);
14980 var previewUrl = "/preview?path=" + encodeURIComponent(path)
14981 + "&include_globs=" + encodeURIComponent(includeValue)
14982 + "&exclude_globs=" + encodeURIComponent(excludeValue);
14983 fetch(previewUrl)
14984 .then(function (response) { return response.text(); })
14985 .then(function (html) {
14986 if (myGen !== _previewGen) return;
14987 clearInterval(window._previewInterval); window._previewInterval = null;
14988 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
14989 previewPanel.innerHTML = html;
14990 attachPreviewInteractions();
14991 syncPythonVisibility();
14992 updateReview();
14993 setTimeout(collapseLanguagePills, 50);
14994 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
14995 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
14996 var sizeText = document.getElementById('project-size-text');
14997 var sizeBtn = document.getElementById('project-size-btn');
14998 // In server mode with upload sizes available, keep the compressed/original pair.
14999 if (SERVER_MODE && window._lastUploadSizes) {
15000 var us = window._lastUploadSizes;
15001 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
15002 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
15003 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
15004 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
15005 } else if (sizeText && projectSize) {
15006 sizeText.textContent = 'Project size: ' + projectSize;
15007 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
15008 } else if (sizeText) {
15009 sizeText.textContent = 'Project size: —';
15010 }
15011 if (zeroWarn) {
15012 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
15013 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
15014 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
15015 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
15016 if (supportedCount === 0 && fileCount > 0) {
15017 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).';
15018 zeroWarn.style.display = '';
15019 } else {
15020 zeroWarn.style.display = 'none';
15021 }
15022 }
15023 })
15024 .catch(function (err) {
15025 if (myGen !== _previewGen) return;
15026 clearInterval(window._previewInterval); window._previewInterval = null;
15027 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
15028 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
15029 });
15030 }
15031
15032 function pickDirectory(targetInput, kind) {
15033 if (SERVER_MODE) {
15034 if (kind === 'output') {
15035 showBannerToast(
15036 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
15037 false,
15038 { top: true, icon: '📁' }
15039 );
15040 return;
15041 }
15042 var inputEl = kind === 'coverage'
15043 ? document.getElementById('cov-upload-input')
15044 : document.getElementById('dir-upload-input');
15045 if (!inputEl) return;
15046 inputEl.onchange = function () {
15047 var files = inputEl.files;
15048 if (!files || files.length === 0) return;
15049 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
15050 if (browseBtn) browseBtn.disabled = true;
15051
15052 function fileToBase64(file) {
15053 return new Promise(function (resolve, reject) {
15054 var reader = new FileReader();
15055 reader.onload = function () {
15056 var b64 = reader.result.split(',')[1];
15057 resolve(b64);
15058 };
15059 reader.onerror = reject;
15060 reader.readAsDataURL(file);
15061 });
15062 }
15063
15064 if (kind === 'coverage') {
15065 var f = files[0];
15066 if (previewPanel && targetInput === pathInput)
15067 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
15068 fileToBase64(f).then(function (b64) {
15069 return fetch('/api/upload-file', {
15070 method: 'POST',
15071 headers: { 'Content-Type': 'application/json' },
15072 body: JSON.stringify({ filename: f.name, content: b64 })
15073 }).then(function (r) { return r.json(); });
15074 })
15075 .then(function (d) {
15076 if (d && d.tmp_path) {
15077 if (coverageInput) coverageInput.value = d.tmp_path;
15078 setCovStatus('idle');
15079 } else if (d && d.error) { showBannerToast(d.error, true); }
15080 })
15081 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
15082 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
15083 } else {
15084 // ── Filter to source-code files only ─────────────────────────
15085 // Binary, generated, and dependency files (node_modules, .git,
15086 // build artifacts) are skipped so they are never uploaded.
15087 var CODE_EXTS = new Set([
15088 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15089 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15090 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15091 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15092 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15093 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
15094 'tf','hcl','proto','thrift','avsc','graphql','gql'
15095 ]);
15096 var codeFiles = [];
15097 for (var i = 0; i < files.length; i++) {
15098 var f = files[i];
15099 var name = f.name;
15100 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
15101 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
15102 codeFiles.push(f); continue;
15103 }
15104 var dot = name.lastIndexOf('.');
15105 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
15106 }
15107 // Collect specific .git metadata files for server-side git detection.
15108 // These have no source extension so they are excluded by the loop above,
15109 // but the server needs them to read branch/commit/author without running git.
15110 var gitMetaFiles = [];
15111 for (var i = 0; i < files.length; i++) {
15112 var f = files[i];
15113 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
15114 var gitIdx = rp.indexOf('/.git/');
15115 if (gitIdx < 0) continue;
15116 var gitRel = rp.slice(gitIdx + 1);
15117 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
15118 gitRel === '.git/logs/HEAD' ||
15119 gitRel.startsWith('.git/refs/heads/') ||
15120 gitRel.startsWith('.git/refs/tags/')) {
15121 gitMetaFiles.push(f);
15122 }
15123 }
15124 var uploadFiles = codeFiles.concat(gitMetaFiles);
15125 var total = files.length;
15126 var kept = codeFiles.length;
15127 if (kept === 0) {
15128 if (previewPanel && targetInput === pathInput)
15129 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
15130 if (browseBtn) browseBtn.disabled = false;
15131 inputEl.value = '';
15132 return;
15133 }
15134
15135 // ── Helper: apply upload result to UI ────────────────────────
15136 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
15137 function applyUploadResult(tmpPath, sizes) {
15138 targetInput.value = tmpPath;
15139 scrollInputToEnd(targetInput);
15140 if (sizes && SERVER_MODE) {
15141 window._lastUploadSizes = sizes;
15142 // Immediately show both sizes before preview loads.
15143 var sizeText = document.getElementById('project-size-text');
15144 var sizeBtn = document.getElementById('project-size-btn');
15145 if (sizeText) {
15146 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15147 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15148 }
15149 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15150 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15151 }
15152 if (targetInput === pathInput) {
15153 updateReportTitleFromPath();
15154 autoSetOutputDir(tmpPath);
15155 fetchProjectHistory(tmpPath);
15156 loadPreview();
15157 suggestCoverageFile(tmpPath);
15158 }
15159 updateReview();
15160 if (browseBtn) browseBtn.disabled = false;
15161 inputEl.value = '';
15162 }
15163
15164 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
15165 if (typeof CompressionStream !== 'undefined') {
15166 if (previewPanel && targetInput === pathInput)
15167 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15168
15169 // Build a minimal POSIX ustar tar header for a single file entry.
15170 function buildUstarHeader(filePath, fileSize) {
15171 var BLOCK = 512;
15172 var hdr = new Uint8Array(BLOCK);
15173 var enc = new TextEncoder();
15174 function wStr(off, len, s) {
15175 var b = enc.encode(s);
15176 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
15177 }
15178 function wOct(off, len, val) {
15179 var s = val.toString(8);
15180 while (s.length < len - 1) s = '0' + s;
15181 wStr(off, len, s + '\0');
15182 }
15183 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
15184 var name = filePath, prefix = '';
15185 if (filePath.length > 99) {
15186 var split = filePath.lastIndexOf('/', 154);
15187 if (split > 0 && filePath.length - split - 1 <= 99) {
15188 prefix = filePath.substring(0, split);
15189 name = filePath.substring(split + 1);
15190 } else { name = filePath.substring(0, 99); }
15191 }
15192 wStr(0, 100, name); // name
15193 wOct(100, 8, 0o000644); // mode
15194 wOct(108, 8, 0); // uid
15195 wOct(116, 8, 0); // gid
15196 wOct(124, 12, fileSize); // size
15197 wOct(136, 12, 0); // mtime (epoch)
15198 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
15199 hdr[156] = 48; // type flag '0' = regular file
15200 wStr(157, 100, ''); // linkname
15201 wStr(257, 6, 'ustar'); // magic
15202 wStr(263, 2, '00'); // version
15203 wStr(265, 32, ''); // uname
15204 wStr(297, 32, ''); // gname
15205 wOct(329, 8, 0); // devmajor
15206 wOct(337, 8, 0); // devminor
15207 wStr(345, 155, prefix); // prefix
15208 // Compute checksum (sum of all bytes, placeholder = 32).
15209 var chk = 0;
15210 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15211 var cs = chk.toString(8);
15212 while (cs.length < 6) cs = '0' + cs;
15213 wStr(148, 8, cs + '\0 ');
15214 return hdr;
15215 }
15216
15217 // Build tar.gz one file at a time, piping through CompressionStream.
15218 // RAM usage = compressed output buffer + one file at a time.
15219 (async function () {
15220 try {
15221 var BLOCK = 512;
15222 var cs = new CompressionStream('gzip');
15223 var writer = cs.writable.getWriter();
15224 var chunks = [];
15225 var reader = cs.readable.getReader();
15226 var collecting = (async function () {
15227 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
15228 })();
15229
15230 for (var i = 0; i < uploadFiles.length; i++) {
15231 var file = uploadFiles[i];
15232 var path = file.webkitRelativePath || file.name;
15233 var buf = await file.arrayBuffer();
15234 var data = new Uint8Array(buf);
15235 // Header block
15236 await writer.write(buildUstarHeader(path, data.length));
15237 // Data padded to 512-byte boundary
15238 if (data.length > 0) {
15239 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
15240 var block = new Uint8Array(padded);
15241 block.set(data);
15242 await writer.write(block);
15243 }
15244 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
15245 if (previewPanel && targetInput === pathInput)
15246 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15247 }
15248 }
15249 // End-of-archive: two 512-byte zero blocks
15250 await writer.write(new Uint8Array(BLOCK * 2));
15251 await writer.close();
15252 await collecting;
15253
15254 var blob = new Blob(chunks, { type: 'application/gzip' });
15255 var sizeMB = (blob.size / 1048576).toFixed(1);
15256 if (previewPanel && targetInput === pathInput)
15257 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
15258
15259 var resp = await fetch('/api/upload-tarball', {
15260 method: 'POST',
15261 headers: { 'Content-Type': 'application/gzip' },
15262 body: blob
15263 });
15264 var d = await resp.json();
15265 if (d && d.tmp_path) {
15266 applyUploadResult(d.tmp_path, {
15267 compressed_bytes: d.compressed_bytes || 0,
15268 original_bytes: d.original_bytes || 0
15269 });
15270 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15271 } catch (e) {
15272 showBannerToast('Upload failed: ' + String(e), true);
15273 if (browseBtn) browseBtn.disabled = false;
15274 inputEl.value = '';
15275 }
15276 })();
15277
15278 } else {
15279 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
15280 // Used only on browsers that lack CompressionStream (pre-2023).
15281 var BATCH = 200;
15282 var batches = [];
15283 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
15284 var totalBatches = batches.length;
15285 if (previewPanel && targetInput === pathInput)
15286 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
15287
15288 function sendBatch(idx, currentUploadId, lastTmpPath) {
15289 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
15290 if (previewPanel && targetInput === pathInput && totalBatches > 1)
15291 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
15292 Promise.all(batches[idx].map(function (file) {
15293 return fileToBase64(file).then(function (b64) {
15294 return { path: file.webkitRelativePath || file.name, content: b64 };
15295 });
15296 })).then(function (fileList) {
15297 var body = { files: fileList };
15298 if (currentUploadId) body.upload_id = currentUploadId;
15299 return fetch('/api/upload-directory', {
15300 method: 'POST', headers: { 'Content-Type': 'application/json' },
15301 body: JSON.stringify(body)
15302 }).then(function (r) { return r.json(); });
15303 }).then(function (d) {
15304 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
15305 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15306 }).catch(function (e) {
15307 showBannerToast('Upload failed: ' + String(e), true);
15308 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
15309 });
15310 }
15311 sendBatch(0, null, '');
15312 }
15313 }
15314 };
15315 inputEl.click();
15316 return;
15317 }
15318
15319 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
15320 if (browseButton) browseButton.disabled = true;
15321
15322 if (previewPanel && targetInput === pathInput) {
15323 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
15324 }
15325
15326 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
15327 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
15328 .then(function (data) {
15329 if (data && data.selected_path) {
15330 targetInput.value = data.selected_path;
15331 scrollInputToEnd(targetInput);
15332
15333 if (targetInput === pathInput) {
15334 updateReportTitleFromPath();
15335 autoSetOutputDir(data.selected_path);
15336 fetchProjectHistory(data.selected_path);
15337 loadPreview();
15338 suggestCoverageFile(data.selected_path);
15339 }
15340
15341 updateReview();
15342 } else if (targetInput === pathInput) {
15343 loadPreview();
15344 }
15345 })
15346 .catch(function () {
15347 window.alert("Directory picker request failed.");
15348 if (previewPanel && targetInput === pathInput) {
15349 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
15350 }
15351 })
15352 .finally(function () {
15353 if (browseButton) browseButton.disabled = false;
15354 });
15355 }
15356
15357 if (themeToggle) {
15358 themeToggle.addEventListener("click", function () {
15359 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
15360 applyTheme(nextTheme);
15361 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
15362 });
15363 }
15364
15365 stepButtons.forEach(function (button) {
15366 button.addEventListener("click", function () {
15367 setStep(Number(button.getAttribute("data-step-target")));
15368 });
15369 });
15370
15371 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
15372 button.addEventListener("click", function () {
15373 setStep(Number(button.getAttribute("data-step-target")) || 1);
15374 });
15375 });
15376
15377 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
15378 button.addEventListener("click", function () {
15379 updateReview();
15380 setStep(Number(button.getAttribute("data-next")));
15381 });
15382 });
15383
15384 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
15385 button.addEventListener("click", function () {
15386 setStep(Number(button.getAttribute("data-prev")));
15387 });
15388 });
15389
15390 document.addEventListener("keydown", function (e) {
15391 var tag = (document.activeElement || {}).tagName || "";
15392 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
15393 if (e.altKey || e.ctrlKey || e.metaKey) return;
15394 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
15395 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
15396 });
15397
15398 if (useSamplePath) {
15399 useSamplePath.addEventListener("click", function () {
15400 pathInput.value = "tests/fixtures/basic";
15401 updateReportTitleFromPath();
15402 autoSetOutputDir("tests/fixtures/basic");
15403 loadPreview();
15404 suggestCoverageFile("tests/fixtures/basic");
15405 });
15406 }
15407
15408 if (useDefaultOutput) {
15409 useDefaultOutput.addEventListener("click", function () {
15410 delete outputDirInput.dataset.userEdited;
15411 autoSetOutputDir(pathInput ? pathInput.value : "");
15412 updateReview();
15413 });
15414 }
15415
15416 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
15417 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
15418
15419 // ── Drag-and-drop directory upload (server mode only) ─────────────────
15420 // Dropping a folder onto the path field bypasses Chrome's
15421 // "Upload X files to this site?" confirmation dialog.
15422 async function readDirRecursively(dirEntry, basePath) {
15423 var reader = dirEntry.createReader();
15424 var all = [];
15425 for (;;) {
15426 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
15427 if (!batch.length) break;
15428 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
15429 }
15430 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
15431 var out = [];
15432 for (var i = 0; i < all.length; i++) {
15433 var sub = all[i];
15434 if (sub.isFile) {
15435 var f = await new Promise(function(res) { sub.file(res); });
15436 out.push({ file: f, path: basePath + '/' + sub.name });
15437 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
15438 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
15439 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
15440 }
15441 }
15442 return out;
15443 }
15444
15445 function setupPathDropZone() {
15446 if (!SERVER_MODE || !pathInput) return;
15447 var CODE_EXTS = new Set([
15448 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15449 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15450 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15451 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15452 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15453 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
15454 ]);
15455 pathInput.addEventListener('dragover', function(e) {
15456 e.preventDefault();
15457 pathInput.classList.add('drag-over');
15458 });
15459 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
15460 pathInput.addEventListener('drop', function(e) {
15461 e.preventDefault();
15462 pathInput.classList.remove('drag-over');
15463 var items = e.dataTransfer.items;
15464 if (!items || !items.length) return;
15465 var dirEntry = null;
15466 for (var i = 0; i < items.length; i++) {
15467 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
15468 if (entry && entry.isDirectory) { dirEntry = entry; break; }
15469 }
15470 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
15471 var btn = browsePath;
15472 if (btn) btn.disabled = true;
15473 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
15474
15475 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
15476 var total = allEntries.length;
15477 var codeEntries = allEntries.filter(function(e) {
15478 var n = e.file.name;
15479 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
15480 var dot = n.lastIndexOf('.');
15481 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
15482 });
15483 var kept = codeEntries.length;
15484 if (kept === 0) {
15485 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
15486 if (btn) btn.disabled = false; return;
15487 }
15488
15489 function finish(tmpPath, sizes) {
15490 pathInput.value = tmpPath;
15491 scrollInputToEnd(pathInput);
15492 if (sizes) {
15493 window._lastUploadSizes = sizes;
15494 var sizeText = document.getElementById('project-size-text');
15495 var sizeBtn = document.getElementById('project-size-btn');
15496 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15497 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15498 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15499 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15500 }
15501 updateReportTitleFromPath();
15502 autoSetOutputDir(tmpPath);
15503 fetchProjectHistory(tmpPath);
15504 loadPreview();
15505 suggestCoverageFile(tmpPath);
15506 updateReview();
15507 if (btn) btn.disabled = false;
15508 }
15509
15510 if (typeof CompressionStream === 'undefined') {
15511 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
15512 if (btn) btn.disabled = false; return;
15513 }
15514
15515 try {
15516 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15517 var BLOCK = 512;
15518 var cs = new CompressionStream('gzip');
15519 var wtr = cs.writable.getWriter();
15520 var chunks = [];
15521 var rdr = cs.readable.getReader();
15522 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
15523
15524 function buildHdr(fp, sz) {
15525 var hdr = new Uint8Array(BLOCK);
15526 var enc = new TextEncoder();
15527 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]; }
15528 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
15529 var nm = fp, pfx = '';
15530 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); } }
15531 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
15532 for (var i = 148; i < 156; i++) hdr[i] = 32;
15533 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);
15534 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15535 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
15536 return hdr;
15537 }
15538
15539 for (var i = 0; i < codeEntries.length; i++) {
15540 var ce = codeEntries[i];
15541 var buf = await ce.file.arrayBuffer();
15542 var data = new Uint8Array(buf);
15543 await wtr.write(buildHdr(ce.path, data.length));
15544 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
15545 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
15546 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15547 }
15548 await wtr.write(new Uint8Array(BLOCK * 2));
15549 await wtr.close();
15550 await collecting;
15551
15552 var blob = new Blob(chunks, { type: 'application/gzip' });
15553 var sizeMB = (blob.size / 1048576).toFixed(1);
15554 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
15555 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
15556 var d = await resp.json();
15557 if (d && d.tmp_path) {
15558 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
15559 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
15560 } catch (err) {
15561 showBannerToast('Upload failed: ' + String(err), true);
15562 if (btn) btn.disabled = false;
15563 }
15564 }).catch(function(err) {
15565 showBannerToast('Could not read folder: ' + String(err), true);
15566 if (btn) btn.disabled = false;
15567 });
15568 });
15569 }
15570 setupPathDropZone();
15571 if (browseCoverage) {
15572 browseCoverage.addEventListener("click", function () {
15573 pickDirectory(coverageInput || pathInput, "coverage");
15574 });
15575 }
15576
15577 function setCovStatus(state, opts) {
15578 if (!covScanStatus) return;
15579 opts = opts || {};
15580 covScanStatus.className = "cov-scan-status cov-scan-" + state;
15581 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
15582 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>';
15583 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>';
15584 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>';
15585 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>';
15586 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
15587 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
15588 if (state === "scanning") {
15589 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
15590 } else if (state === "found") {
15591 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15592 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
15593 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
15594 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
15595 } else if (state === "hint") {
15596 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15597 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
15598 html += '<div class="cov-scan-sub">Generate one with:</div>';
15599 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
15600 } else if (state === "none") {
15601 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
15602 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
15603 }
15604 html += '</div></div>';
15605 covScanStatus.innerHTML = html;
15606 if (state === "found") {
15607 var useBtn = covScanStatus.querySelector(".cov-scan-use");
15608 if (useBtn) useBtn.addEventListener("click", function () {
15609 if (coverageInput) coverageInput.value = "";
15610 covAutoFilled = false;
15611 setCovStatus("idle");
15612 });
15613 }
15614 }
15615
15616 function suggestCoverageFile(projectPath) {
15617 if (!coverageInput || !covScanStatus) return;
15618 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15619 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
15620 clearTimeout(coverageSuggestTimer);
15621 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
15622 setCovStatus("scanning");
15623 coverageSuggestTimer = setTimeout(function () {
15624 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
15625 .then(function (r) { return r.json(); })
15626 .then(function (d) {
15627 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15628 if (!d) { setCovStatus("none"); return; }
15629 if (d.found) {
15630 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
15631 setCovStatus("found", { found: d.found, tool: d.tool });
15632 } else if (d.tool && d.hint) {
15633 setCovStatus("hint", { tool: d.tool, hint: d.hint });
15634 } else {
15635 setCovStatus("none");
15636 }
15637 })
15638 .catch(function () { setCovStatus("idle"); });
15639 }, 600);
15640 }
15641
15642 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
15643
15644 if (coverageInput) coverageInput.addEventListener("input", function () {
15645 covAutoFilled = false;
15646 if (!this.value.trim()) setCovStatus("idle");
15647 });
15648
15649 // ── Language pill overflow: collapse to "+N more" chip ─────────────
15650 function collapseLanguagePills() {
15651 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
15652 rows.forEach(function(row) {
15653 // Remove any previous overflow chip
15654 var prev = row.querySelector('.lang-overflow-chip');
15655 if (prev) prev.remove();
15656 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
15657 pills.forEach(function(p) { p.style.display = ''; });
15658 if (!pills.length) return;
15659
15660 // Measure after restoring all pills
15661 var containerRight = row.getBoundingClientRect().right;
15662 var hidden = [];
15663 for (var i = pills.length - 1; i >= 1; i--) {
15664 var rect = pills[i].getBoundingClientRect();
15665 if (rect.right > containerRight + 2) {
15666 hidden.unshift(pills[i]);
15667 pills[i].style.display = 'none';
15668 } else {
15669 break;
15670 }
15671 }
15672
15673 if (hidden.length) {
15674 var chip = document.createElement('button');
15675 chip.type = 'button';
15676 chip.className = 'language-pill lang-overflow-chip';
15677 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
15678 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
15679 row.appendChild(chip);
15680 }
15681 });
15682 }
15683
15684 // Run after preview loads (preview panel populates language pills)
15685 var _origLoadPreviewCb = window.__previewLoaded;
15686 document.addEventListener('previewLoaded', collapseLanguagePills);
15687 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
15688 setTimeout(collapseLanguagePills, 400);
15689
15690 // ── Project history & output dir auto-set ──────────────────────────
15691 var wsOutputRoot = document.getElementById("ws-output-root");
15692 var wsScanCount = document.getElementById("ws-scan-count");
15693 var wsLastScan = document.getElementById("ws-last-scan");
15694 var historyBadge = document.getElementById("path-history-badge");
15695 var historyTimer = null;
15696
15697 var wsOutputLink = document.getElementById("ws-output-link");
15698 function syncStripOutputRoot() {
15699 var val = outputDirInput ? outputDirInput.value : "";
15700 var display = val || "project/sloc";
15701 if (wsOutputRoot) wsOutputRoot.textContent = display;
15702 if (wsOutputLink) wsOutputLink.dataset.folder = val;
15703 }
15704
15705 function scrollInputToEnd(input) {
15706 if (!input) return;
15707 // Defer so the DOM has the new value before we measure scroll width.
15708 requestAnimationFrame(function () {
15709 input.scrollLeft = input.scrollWidth;
15710 input.selectionStart = input.selectionEnd = input.value.length;
15711 });
15712 }
15713
15714 function autoSetOutputDir(projectPath) {
15715 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
15716 if (GIT_MODE && GIT_OUTPUT_DIR) {
15717 outputDirInput.value = GIT_OUTPUT_DIR;
15718 scrollInputToEnd(outputDirInput);
15719 syncStripOutputRoot();
15720 updateReview();
15721 return;
15722 }
15723 if (!projectPath || !projectPath.trim()) return;
15724 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
15725 outputDirInput.value = cleaned + "/sloc";
15726 scrollInputToEnd(outputDirInput);
15727 syncStripOutputRoot();
15728 updateReview();
15729 }
15730
15731 var wsBranch = document.getElementById("ws-branch");
15732
15733 function fetchProjectHistory(projectPath) {
15734 if (!projectPath || !projectPath.trim()) {
15735 if (wsScanCount) wsScanCount.textContent = "—";
15736 if (wsLastScan) wsLastScan.textContent = "—";
15737 if (wsBranch) wsBranch.textContent = "—";
15738 if (historyBadge) historyBadge.style.display = "none";
15739 return;
15740 }
15741 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
15742 .then(function (r) { return r.ok ? r.json() : null; })
15743 .then(function (data) {
15744 if (!data) return;
15745 var countStr = data.scan_count > 0
15746 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
15747 : "never";
15748 var tsStr = data.last_scan_timestamp
15749 ? data.last_scan_timestamp.replace(" UTC","")
15750 : "—";
15751 if (wsScanCount) wsScanCount.textContent = countStr;
15752 if (wsLastScan) wsLastScan.textContent = tsStr;
15753 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
15754 if (data.scan_count > 0) {
15755 if (historyBadge) {
15756 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
15757 historyBadge.textContent = data.scan_count + " previous scan" +
15758 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
15759 "Last: " + (data.last_scan_timestamp || "—") +
15760 " — " + (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.";
15761 historyBadge.className = "path-history-badge found";
15762 historyBadge.style.display = "";
15763 }
15764 } else {
15765 if (historyBadge) historyBadge.style.display = "none";
15766 }
15767 })
15768 .catch(function () {});
15769 }
15770
15771 function onPathChange() {
15772 var val = pathInput ? pathInput.value : "";
15773 // Discard stale upload sizes when the user edits the path manually.
15774 window._lastUploadSizes = null;
15775 updateReportTitleFromPath();
15776 autoSetOutputDir(val);
15777 updateSidebarSummary();
15778 clearTimeout(historyTimer);
15779 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
15780 if (previewTimer) clearTimeout(previewTimer);
15781 previewTimer = setTimeout(loadPreview, 280);
15782 suggestCoverageFile(val);
15783 }
15784
15785 if (pathInput) {
15786 pathInput.addEventListener("input", onPathChange);
15787 }
15788
15789 if (outputDirInput) {
15790 outputDirInput.addEventListener("input", function () {
15791 outputDirInput.dataset.userEdited = "1";
15792 syncStripOutputRoot();
15793 updateReview();
15794 });
15795 }
15796
15797 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
15798 if (!node) return;
15799 node.addEventListener("input", function () {
15800 updateReview();
15801 if (previewTimer) clearTimeout(previewTimer);
15802 previewTimer = setTimeout(loadPreview, 280);
15803 });
15804 });
15805
15806 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
15807 var node = document.getElementById(id);
15808 if (node) node.addEventListener("change", updateReview);
15809 });
15810
15811 if (reportTitleInput) {
15812 reportTitleInput.addEventListener("input", function () {
15813 reportTitleTouched = reportTitleInput.value.trim().length > 0;
15814 updateReportTitleFromPath();
15815 updateReview();
15816 });
15817 }
15818
15819 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
15820 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
15821 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
15822 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
15823
15824 if (coverageInput) {
15825 coverageInput.addEventListener("input", function () {
15826 if (coverageInput.value.trim()) setCovStatus("idle");
15827 });
15828 }
15829
15830 if (form && loading && submitButton) {
15831 form.addEventListener("submit", function (e) {
15832 e.preventDefault();
15833 submitButton.disabled = true;
15834 submitButton.textContent = "Scanning...";
15835 startAsyncAnalysis(new FormData(form));
15836 });
15837 }
15838
15839 function openPath(folder) {
15840 if (!folder) return;
15841 fetch('/open-path?path=' + encodeURIComponent(folder))
15842 .then(function (r) { return r.json(); })
15843 .then(function (d) {
15844 if (d && d.server_mode_disabled)
15845 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
15846 })
15847 .catch(function () {});
15848 }
15849
15850 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
15851 btn.addEventListener('click', function () {
15852 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
15853 });
15854 });
15855
15856 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
15857 if (wsOutputLink) {
15858 wsOutputLink.addEventListener('click', function () {
15859 openPath(wsOutputLink.dataset.folder || '');
15860 });
15861 }
15862
15863 loadSavedTheme();
15864 updateMixedPolicyUI();
15865 updatePythonDocstringUI();
15866 applyScanPreset();
15867 updatePresetDescriptions();
15868 applyArtifactPreset();
15869 updateReview();
15870 updateScrollProgress(); // initialise bar to 0% (step 1)
15871 window.addEventListener("scroll", updateScrollProgress, { passive: true });
15872 onPathChange(); // seed output dir, history badge, and preview from initial path
15873 updateStepNav(1);
15874
15875 // Restore step from URL hash on initial load (e.g., back-forward cache)
15876 (function() {
15877 var hashMatch = location.hash.match(/^#step([1-4])$/);
15878 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
15879 })();
15880
15881 (function randomizeWatermarks() {
15882 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
15883 if (!wms.length) return;
15884 var placed = [];
15885 function tooClose(top, left) {
15886 for (var i = 0; i < placed.length; i++) {
15887 var dt = Math.abs(placed[i][0] - top);
15888 var dl = Math.abs(placed[i][1] - left);
15889 if (dt < 16 && dl < 12) return true;
15890 }
15891 return false;
15892 }
15893 function pick(leftBand) {
15894 for (var attempt = 0; attempt < 50; attempt++) {
15895 var top = Math.random() * 88 + 2;
15896 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
15897 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
15898 }
15899 var top = Math.random() * 88 + 2;
15900 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
15901 placed.push([top, left]);
15902 return [top, left];
15903 }
15904 var half = Math.floor(wms.length / 2);
15905 wms.forEach(function (img, i) {
15906 var pos = pick(i < half);
15907 var size = Math.floor(Math.random() * 80 + 110);
15908 var rot = (Math.random() * 360).toFixed(1);
15909 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
15910 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;
15911 });
15912 })();
15913
15914 (function spawnCodeParticles() {
15915 var container = document.getElementById('code-particles');
15916 if (!container) return;
15917 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'];
15918 for (var i = 0; i < 38; i++) {
15919 (function(idx) {
15920 var el = document.createElement('span');
15921 el.className = 'code-particle';
15922 el.textContent = snippets[idx % snippets.length];
15923 var left = Math.random() * 94 + 2;
15924 var top = Math.random() * 88 + 6;
15925 var dur = (Math.random() * 10 + 9).toFixed(1);
15926 var delay = (Math.random() * 18).toFixed(1);
15927 var rot = (Math.random() * 26 - 13).toFixed(1);
15928 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15929 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';
15930 container.appendChild(el);
15931 })(i);
15932 }
15933 })();
15934 })();
15935 </script>
15936 <script nonce="{{ csp_nonce }}">
15937 (function () {
15938 var raw = {{ prefill_json|safe }};
15939 if (!raw || typeof raw !== 'object' || !raw.path) return;
15940 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
15941 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
15942 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
15943 setVal('path', raw.path || '');
15944 setVal('include_globs', raw.include_globs || '');
15945 setVal('exclude_globs', raw.exclude_globs || '');
15946 setVal('output_dir', raw.output_dir || '');
15947 setVal('report_title', raw.report_title || '');
15948 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
15949 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
15950 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
15951 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
15952 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
15953 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
15954 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
15955 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
15956 setChecked('generate_html', raw.generate_html !== false);
15957 setChecked('generate_pdf', !!raw.generate_pdf);
15958 // Trigger dynamic UI updates after pre-fill.
15959 setTimeout(function () {
15960 var pathEl = document.getElementById('path');
15961 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
15962 var policyEl = document.getElementById('mixed_line_policy');
15963 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
15964 }, 80);
15965 })();
15966 </script>
15967 <script nonce="{{ csp_nonce }}">
15968 (function(){
15969 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'}];
15970 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);});}
15971 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15972 function init(){
15973 var btn=document.getElementById('settings-btn');if(!btn)return;
15974 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15975 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>';
15976 document.body.appendChild(m);
15977 var g=document.getElementById('scheme-grid');
15978 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);});
15979 var cl=document.getElementById('settings-close');
15980 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);
15981 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');});
15982 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15983 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15984 }
15985 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15986 }());
15987 </script>
15988 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
15989 <div class="wb-ftip-arrow"></div>
15990 <span id="wb-ftip-text"></span>
15991 </div>
15992 <script nonce="{{ csp_nonce }}">(function(){
15993 var tip=document.getElementById('wb-ftip');
15994 var txt=document.getElementById('wb-ftip-text');
15995 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
15996 if(!tip||!txt)return;
15997 function pos(el){
15998 var r=el.getBoundingClientRect();
15999 tip.style.display='block';
16000 var tw=tip.offsetWidth;
16001 var lx=r.left+r.width/2-tw/2;
16002 if(lx<8)lx=8;
16003 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
16004 tip.style.left=lx+'px';
16005 tip.style.top=(r.bottom+8)+'px';
16006 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';}
16007 }
16008 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
16009 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
16010 el.addEventListener('mouseleave',function(){tip.style.display='none';});
16011 });
16012 })();
16013 (function(){
16014 function fixArtifactHintSpacing(){
16015 var grid=document.querySelector('.artifact-grid');
16016 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
16017 }
16018 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
16019 }());
16020 (function(){
16021 var dot=document.getElementById('status-dot');
16022 var pingEl=document.getElementById('server-ping-ms');
16023 var tipEl=document.getElementById('server-tip-ping');
16024 var fm=document.getElementById('footer-mode');
16025 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)';}}
16026 function doPing(){
16027 var t0=performance.now();
16028 fetch('/healthz',{cache:'no-store'})
16029 .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);})
16030 .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)';}});
16031 }
16032 doPing();
16033 setInterval(doPing,5000);
16034 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');}
16035 })();
16036 </script>
16037 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
16038 <footer class="site-footer">
16039 local code analysis - metrics, history and reports
16040 · <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>
16041 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16042 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16043 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16044 · <a href="/api-docs" rel="noopener">REST API</a>
16045 </footer>
16046</body>
16047</html>
16048"##,
16049 ext = "html"
16050)]
16051struct IndexTemplate {
16052 version: &'static str,
16053 prefill_json: String,
16054 csp_nonce: String,
16055 git_repo: String,
16056 git_ref: String,
16057 git_label_json: String,
16058 git_output_dir_json: String,
16059 server_mode: bool,
16060}
16061
16062#[derive(Template)]
16065#[template(
16066 source = r##"
16067<!doctype html>
16068<html lang="en">
16069<head>
16070 <meta charset="utf-8">
16071 <meta name="viewport" content="width=device-width, initial-scale=1">
16072 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
16073 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16074 <style nonce="{{ csp_nonce }}">
16075 :root {
16076 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
16077 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16078 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16079 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16080 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
16081 }
16082 body.dark-theme {
16083 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
16084 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
16085 }
16086 *{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;}
16087 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16088 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16089 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16090 .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;}
16091 @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));}}
16092 .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);}
16093 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16094 .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));}
16095 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16096 .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;}
16097 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16098 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16099 @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; } }
16100 .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;}
16101 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16102 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
16103 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16104 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16105 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16106 .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;}
16107 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16108 .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);}
16109 .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;}
16110 .settings-close:hover{color:var(--text);background:var(--surface-2);}
16111 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16112 .settings-modal-body{padding:14px 16px 16px;}
16113 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16114 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16115 .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;}
16116 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16117 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16118 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16119 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16120 .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;}
16121 .tz-select:focus{border-color:var(--oxide);}
16122 .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;}
16123 .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;}
16124 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
16125 .hero{text-align:center;margin:0 auto 18px;}
16126 .hero-logo-wrap{display:inline-block;cursor:default;}
16127 .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;}
16128 .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;}
16129 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
16130 .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;}
16131 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%);}
16132 .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;
16133 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
16134 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
16135 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;}
16136 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
16137 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
16138 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;}
16139 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
16140 .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;}
16141 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
16142 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
16143 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
16144 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
16145 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
16146 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
16147 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
16148 .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;}
16149 .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;}
16150 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
16151 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
16152 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16153 .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);}
16154 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
16155 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
16156 .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);}
16157 .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);}
16158 .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);}
16159 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
16160 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
16161 .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;}
16162 body.dark-theme .action-card-cta{color:var(--oxide);}
16163 .action-card.view .action-card-cta{color:var(--accent-2);}
16164 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
16165 .action-card.compare .action-card-cta{color:#7c3aed;}
16166 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
16167 .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);}
16168 .action-card.git-tools .action-card-cta{color:#15803d;}
16169 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
16170 .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);}
16171 .action-card.trend .action-card-cta{color:#0e7490;}
16172 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
16173 .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);}
16174 .action-card.automation .action-card-cta{color:#b45309;}
16175 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
16176 .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);}
16177 .action-card.test-metrics .action-card-cta{color:#be185d;}
16178 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
16179 .action-card:hover .action-card-cta{gap:12px;}
16180 .action-card.card-split{flex-direction:row;align-items:stretch;}
16181 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
16182 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
16183 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
16184 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
16185 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
16186 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
16187 .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;}
16188 .ac-badge.active{opacity:1;}
16189 .ac-badge.github{border-color:#555;color:#555;}
16190 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
16191 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
16192 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
16193 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
16194 body.dark-theme .ac-right-row{color:var(--muted);}
16195 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
16196 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
16197 .divider{height:1px;background:var(--line);margin:32px 0;}
16198 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
16199 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
16200 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
16201 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
16202 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
16203 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16204 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
16205 body.dark-theme .info-chip-val{color:var(--oxide);}
16206 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
16207 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
16208 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
16209 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
16210 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
16211 border:6px solid transparent;border-top-color:var(--text);}
16212 .info-chip:hover .info-chip-tip{display:block;}
16213 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
16214 .chip-slide.fading{filter:blur(5px);opacity:0;}
16215 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16216 .site-footer a{color:var(--muted);}
16217 .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;}
16218 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
16219 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
16220 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
16221 .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;}
16222 .lan-badge.local{background:var(--oxide-2);}
16223 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
16224 .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);}
16225 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
16226 .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;}
16227 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
16228 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
16229 .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;}
16230 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
16231 .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;}
16232 .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);}
16233 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
16234 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
16235 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
16236 .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;}
16237 @media (max-height: 1100px) {
16238 .page{padding-top:10px;}
16239 .hero{margin-bottom:10px;}
16240 .hero-logo{width:54px;height:60px;}
16241 .hero-logo-shadow{width:42px;}
16242 .hero-title{font-size:28px;}
16243 .hero-subtitle{font-size:13px;}
16244 .card-sections{gap:12px;margin-bottom:6px;}
16245 .card-section-grid-2,.card-section-grid-3{gap:10px;}
16246 .action-card{padding:8px 15px 8px;}
16247 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
16248 .action-card-icon svg{width:18px;height:18px;}
16249 .action-card-title{font-size:13px;}
16250 .action-card-desc{font-size:11px;margin-bottom:6px;}
16251 .action-card-cta{font-size:11px;}
16252 .ac-right-row{font-size:11px;}
16253 .divider{margin:14px 0;}
16254 .info-strip{gap:7px;margin-bottom:8px;}
16255 .info-chip{padding:7px 10px;}
16256 .info-chip-val{font-size:13px;}
16257 .info-chip-label{font-size:9px;}
16258 .site-footer{padding:8px 24px;font-size:12px;}
16259 .lan-local-hint{margin-top:8px;}
16260 }
16261 @media (max-height: 850px) {
16262 .page{padding-top:6px;}
16263 .hero{margin-bottom:6px;}
16264 .hero-logo{width:42px;height:46px;}
16265 .hero-title{font-size:22px;}
16266 .hero-subtitle{font-size:12px;}
16267 .card-sections{gap:10px;}
16268 .action-card-desc{margin-bottom:4px;}
16269 .divider{margin:8px 0;}
16270 .info-strip{margin-bottom:6px;}
16271 .lan-local-hint{margin-top:10px;}
16272 }
16273 </style>
16274</head>
16275<body>
16276 <div class="background-watermarks" aria-hidden="true">
16277 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16278 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16279 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16280 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16281 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16282 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16283 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16284 </div>
16285 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16286 <div class="top-nav">
16287 <div class="top-nav-inner">
16288 <a class="brand" href="/">
16289 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16290 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16291 </a>
16292 <div class="nav-right">
16293 <a class="nav-pill" href="/">Home</a>
16294 <div class="nav-dropdown">
16295 <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>
16296 <div class="nav-dropdown-menu">
16297 <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>
16298 </div>
16299 </div>
16300 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16301 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16302 <div class="nav-dropdown">
16303 <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>
16304 <div class="nav-dropdown-menu">
16305 <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>
16306 </div>
16307 </div>
16308 <div class="server-status-wrap" id="server-status-wrap">
16309 <div class="nav-pill server-online-pill" id="server-status-pill">
16310 <span class="status-dot" id="status-dot"></span>
16311 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
16312 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16313 </div>
16314 <div class="server-status-tip">
16315 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
16316 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16317 </div>
16318 </div>
16319 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16320 <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>
16321 </button>
16322 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16323 <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>
16324 <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>
16325 </button>
16326 </div>
16327 </div>
16328 </div>
16329
16330 <div class="page">
16331 <div class="hero">
16332 <div class="hero-logo-wrap" id="hero-logo-wrap">
16333 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
16334 </div>
16335 <div class="hero-logo-shadow"></div>
16336 <div class="hero-title-wrap">
16337 <div class="hero-title-aura" aria-hidden="true"></div>
16338 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
16339 </div>
16340 <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>
16341 </div>
16342
16343 <div class="card-sections">
16344
16345 <div>
16346 <div class="card-section-label">Analysis</div>
16347 <div class="card-section-grid-2">
16348 <a class="action-card scan card-split" href="/scan-setup">
16349 <div class="action-card-left">
16350 <div class="action-card-icon">
16351 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
16352 </div>
16353 <div class="action-card-title">Scan Project</div>
16354 <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>
16355 <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>
16356 </div>
16357 <div class="action-card-sep"></div>
16358 <div class="action-card-right">
16359 <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>
16360 <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>
16361 <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>
16362 <div class="ac-right-stat" id="acp-scan-stat"></div>
16363 </div>
16364 </a>
16365 <a class="action-card test-metrics card-split" href="/test-metrics">
16366 <div class="action-card-left">
16367 <div class="action-card-icon">
16368 <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>
16369 </div>
16370 <div class="action-card-title">Test Metrics</div>
16371 <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>
16372 <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>
16373 </div>
16374 <div class="action-card-sep"></div>
16375 <div class="action-card-right">
16376 <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>
16377 <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>
16378 <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>
16379 <div class="ac-right-stat" id="acp-test-stat"></div>
16380 </div>
16381 </a>
16382 </div>
16383 </div>
16384
16385 <div>
16386 <div class="card-section-label">Reports & Insights</div>
16387 <div class="card-section-grid-3">
16388 <a class="action-card view" href="/view-reports">
16389 <div class="action-card-icon">
16390 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
16391 </div>
16392 <div class="action-card-title">View Reports</div>
16393 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
16394 <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>
16395 </a>
16396 <a class="action-card compare" href="/compare-scans">
16397 <div class="action-card-icon">
16398 <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>
16399 </div>
16400 <div class="action-card-title">Compare Scans</div>
16401 <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>
16402 <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>
16403 </a>
16404 <a class="action-card trend" href="/trend-reports">
16405 <div class="action-card-icon">
16406 <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>
16407 </div>
16408 <div class="action-card-title">Trend Report</div>
16409 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
16410 <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>
16411 </a>
16412 </div>
16413 </div>
16414
16415 <div>
16416 <div class="card-section-label">Developer Tools</div>
16417 <div class="card-section-grid-2">
16418 <a class="action-card git-tools card-split" href="/git-browser">
16419 <div class="action-card-left">
16420 <div class="action-card-icon">
16421 <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>
16422 </div>
16423 <div class="action-card-title">Git Browser</div>
16424 <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>
16425 <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>
16426 </div>
16427 <div class="action-card-sep"></div>
16428 <div class="action-card-right">
16429 <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>
16430 <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>
16431 <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>
16432 </div>
16433 </a>
16434 <a class="action-card automation card-split" href="/integrations">
16435 <div class="action-card-left">
16436 <div class="action-card-icon">
16437 <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>
16438 </div>
16439 <div class="action-card-title">Integrations</div>
16440 <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>
16441 <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>
16442 </div>
16443 <div class="action-card-sep"></div>
16444 <div class="action-card-right">
16445 <div class="ac-badges-grid">
16446 <span class="ac-badge github" id="acp-gh">GitHub</span>
16447 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
16448 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
16449 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
16450 </div>
16451 <div class="ac-right-stat" id="acp-int-stat"></div>
16452 </div>
16453 </a>
16454 </div>
16455 </div>
16456
16457 </div>
16458
16459 {% if server_mode %}
16460 <div class="lan-card server">
16461 <div class="lan-card-header">
16462 <span class="lan-badge">LAN server</span>
16463 Accessible on your network
16464 </div>
16465 {% if let Some(ip) = lan_ip %}
16466 <div class="lan-url-row">
16467 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
16468 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
16469 <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>
16470 Copy URL
16471 </button>
16472 </div>
16473 <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>
16474 {% if has_api_key %}
16475 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
16476 {% endif %}
16477 {% else %}
16478 <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>
16479 {% endif %}
16480 </div>
16481 {% endif %}
16482
16483 <div class="divider"></div>
16484
16485 <div class="info-strip">
16486 <div class="info-chip">
16487 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
16488 <div class="chip-slide">
16489 <div class="info-chip-val">41</div>
16490 <div class="info-chip-label">Languages</div>
16491 </div>
16492 </div>
16493 <div class="info-chip">
16494 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
16495 <div class="chip-slide">
16496 <div class="info-chip-val">100%</div>
16497 <div class="info-chip-label">Self-contained</div>
16498 </div>
16499 </div>
16500 <div class="info-chip">
16501 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
16502 <div class="chip-slide">
16503 <div class="info-chip-val">HTML+PDF</div>
16504 <div class="info-chip-label">Exportable reports</div>
16505 </div>
16506 </div>
16507 <div class="info-chip">
16508 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
16509 <div class="chip-slide">
16510 <div class="info-chip-val">Webhook</div>
16511 <div class="info-chip-label">3 platforms</div>
16512 </div>
16513 </div>
16514 <div class="info-chip">
16515 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
16516 <div class="chip-slide">
16517 <div class="info-chip-val">IEEE</div>
16518 <div class="info-chip-label">1045-1992</div>
16519 </div>
16520 </div>
16521 </div>
16522
16523 {% if lan_ip.is_none() %}
16524 <div class="lan-local-hint">
16525 <strong>Want teammates on the same network to access this?</strong><br>
16526 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
16527 </div>
16528 {% endif %}
16529 </div>
16530
16531 <footer class="site-footer">
16532 local code analysis - metrics, history and reports
16533 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
16534 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16535 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16536 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16537 · <a href="/api-docs" rel="noopener">REST API</a>
16538 </footer>
16539
16540 <script nonce="{{ csp_nonce }}">
16541 (function () {
16542 var storageKey = 'oxide-sloc-theme';
16543 var body = document.body;
16544 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16545 var toggle = document.getElementById('theme-toggle');
16546 if (toggle) toggle.addEventListener('click', function () {
16547 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16548 body.classList.toggle('dark-theme', next === 'dark');
16549 try { localStorage.setItem(storageKey, next); } catch(e) {}
16550 });
16551 var copyBtn = document.getElementById('lan-copy-btn');
16552 if (copyBtn) copyBtn.addEventListener('click', function() {
16553 var btn = this;
16554 var el = document.getElementById('lan-url-val');
16555 if (!el) return;
16556 var url = el.textContent.trim();
16557 if (navigator.clipboard) {
16558 navigator.clipboard.writeText(url).then(function() {
16559 var orig = btn.innerHTML;
16560 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!';
16561 setTimeout(function() { btn.innerHTML = orig; }, 1800);
16562 });
16563 }
16564 });
16565 (function randomizeWatermarks() {
16566 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16567 if (!wms.length) return;
16568 var placed = [];
16569 function tooClose(top, left) {
16570 for (var i = 0; i < placed.length; i++) {
16571 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
16572 if (dt < 16 && dl < 12) return true;
16573 }
16574 return false;
16575 }
16576 function pick(leftBand) {
16577 for (var attempt = 0; attempt < 50; attempt++) {
16578 var top = Math.random() * 88 + 2;
16579 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16580 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16581 }
16582 var top = Math.random() * 88 + 2;
16583 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16584 placed.push([top, left]); return [top, left];
16585 }
16586 var half = Math.floor(wms.length / 2);
16587 wms.forEach(function (img, i) {
16588 var pos = pick(i < half);
16589 var size = Math.floor(Math.random() * 100 + 120);
16590 var rot = (Math.random() * 360).toFixed(1);
16591 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
16592 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;
16593 });
16594 })();
16595
16596 (function spawnCodeParticles() {
16597 var container = document.getElementById('code-particles');
16598 if (!container) return;
16599 var snippets = [
16600 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
16601 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
16602 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
16603 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
16604 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
16605 ];
16606 var count = 38;
16607 for (var i = 0; i < count; i++) {
16608 (function(idx) {
16609 var el = document.createElement('span');
16610 el.className = 'code-particle';
16611 var text = snippets[idx % snippets.length];
16612 el.textContent = text;
16613 var left = Math.random() * 94 + 2;
16614 var top = Math.random() * 88 + 6;
16615 var dur = (Math.random() * 10 + 9).toFixed(1);
16616 var delay = (Math.random() * 18).toFixed(1);
16617 var rot = (Math.random() * 26 - 13).toFixed(1);
16618 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16619 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
16620 + '--rot:' + rot + 'deg;--op:' + op + ';'
16621 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
16622 container.appendChild(el);
16623 })(i);
16624 }
16625 })();
16626 (function heroAnimations() {
16627 var sub = document.getElementById('hero-subtitle');
16628 if (sub) {
16629 var full = sub.textContent.trim();
16630 sub.textContent = '';
16631 sub.style.opacity = '1';
16632 var cursor = document.createElement('span');
16633 cursor.className = 'hero-cursor';
16634 sub.appendChild(cursor);
16635 var i = 0;
16636 setTimeout(function() {
16637 var iv = setInterval(function() {
16638 if (i < full.length) {
16639 sub.insertBefore(document.createTextNode(full[i]), cursor);
16640 i++;
16641 } else {
16642 clearInterval(iv);
16643 setTimeout(function() {
16644 cursor.style.transition = 'opacity 1s ease';
16645 cursor.style.opacity = '0';
16646 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
16647 }, 2400);
16648 }
16649 }, 11);
16650 }, 374);
16651 }
16652 })();
16653 (function logoBob() {
16654 var logo = document.querySelector('.hero-logo');
16655 var shadow = document.querySelector('.hero-logo-shadow');
16656 if (!logo) return;
16657 var cycleStart = null, cycleDur = 3600;
16658 var peakY = -14, peakScale = 1.07, peakRot = 0;
16659 function newCycle() {
16660 cycleDur = 3000 + Math.random() * 1840;
16661 peakY = -(9 + Math.random() * 13.8);
16662 peakScale = 1.04 + Math.random() * 0.081;
16663 peakRot = (Math.random() * 11.5 - 5.75);
16664 }
16665 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
16666 newCycle();
16667 function frame(ts) {
16668 if (cycleStart === null) cycleStart = ts;
16669 var t = (ts - cycleStart) / cycleDur;
16670 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
16671 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
16672 var y = peakY * phase;
16673 var sc = 1 + (peakScale - 1) * phase;
16674 var rot = peakRot * Math.sin(Math.PI * phase);
16675 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
16676 if (shadow) {
16677 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
16678 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
16679 }
16680 requestAnimationFrame(frame);
16681 }
16682 requestAnimationFrame(frame);
16683 })();
16684 (function mouseEffects() {
16685 var heroTitle = document.getElementById('hero-title');
16686 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
16687 function tick() {
16688 raf = null;
16689 if (heroTitle) {
16690 var r = heroTitle.getBoundingClientRect();
16691 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
16692 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
16693 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
16694 }
16695 }
16696 document.addEventListener('mousemove', function(e) {
16697 mx = e.clientX; my = e.clientY;
16698 if (!raf) raf = requestAnimationFrame(tick);
16699 });
16700 document.addEventListener('mouseleave', function() {
16701 if (heroTitle) {
16702 heroTitle.style.transition = 'transform 0.5s ease';
16703 heroTitle.style.transform = '';
16704 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
16705 }
16706 });
16707 document.querySelectorAll('.action-card').forEach(function(card) {
16708 card.addEventListener('mousemove', function(e) {
16709 var rect = card.getBoundingClientRect();
16710 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
16711 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
16712 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
16713 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
16714 });
16715 card.addEventListener('mouseleave', function() {
16716 card.style.transition = '';
16717 card.style.transform = '';
16718 });
16719 });
16720 })();
16721 (function chipSlideshow() {
16722 var slides = [
16723 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
16724 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
16725 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
16726 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
16727 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
16728 ];
16729 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
16730 var indices = [0,0,0,0,0];
16731 var paused = [false,false,false,false,false];
16732 chips.forEach(function(chip, i) {
16733 chip.addEventListener('mouseenter', function() { paused[i] = true; });
16734 chip.addEventListener('mouseleave', function() { paused[i] = false; });
16735 });
16736 function advance(i) {
16737 if (paused[i]) return;
16738 var chip = chips[i];
16739 var inner = chip.querySelector('.chip-slide');
16740 if (!inner) return;
16741 inner.classList.add('fading');
16742 setTimeout(function() {
16743 indices[i] = (indices[i] + 1) % slides[i].length;
16744 var s = slides[i][indices[i]];
16745 chip.querySelector('.info-chip-val').textContent = s.v;
16746 chip.querySelector('.info-chip-label').textContent = s.l;
16747 inner.classList.remove('fading');
16748 }, 720);
16749 }
16750 setInterval(function() {
16751 chips.forEach(function(chip, i) { advance(i); });
16752 }, 6000);
16753 })();
16754 (function cardLiveData() {
16755 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
16756 var el = document.getElementById('acp-scan-stat');
16757 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
16758 }).catch(function(){});
16759 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
16760 var el = document.getElementById('acp-test-stat');
16761 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
16762 }).catch(function(){});
16763 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
16764 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
16765 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
16766 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
16767 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
16768 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
16769 var stat = document.getElementById('acp-int-stat');
16770 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
16771 }).catch(function(){});
16772 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
16773 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
16774 }).catch(function(){});
16775 })();
16776 })();
16777 </script>
16778 <script nonce="{{ csp_nonce }}">
16779 (function(){
16780 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'}];
16781 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);});}
16782 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16783 function init(){
16784 var btn=document.getElementById('settings-btn');if(!btn)return;
16785 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16786 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>';
16787 document.body.appendChild(m);
16788 var g=document.getElementById('scheme-grid');
16789 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);});
16790 var cl=document.getElementById('settings-close');
16791 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);
16792 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');});
16793 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16794 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16795 }
16796 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16797 }());
16798 </script>
16799 <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>
16800</body>
16801</html>
16802"##,
16803 ext = "html"
16804)]
16805struct SplashTemplate {
16806 csp_nonce: String,
16807 server_mode: bool,
16808 lan_ip: Option<String>,
16809 port: u16,
16810 version: &'static str,
16811 has_api_key: bool,
16812}
16813
16814#[derive(Template)]
16817#[template(
16818 source = r##"
16819<!doctype html>
16820<html lang="en">
16821<head>
16822 <meta charset="utf-8">
16823 <meta name="viewport" content="width=device-width, initial-scale=1">
16824 <title>OxideSLOC — Start a Scan</title>
16825 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16826 <style nonce="{{ csp_nonce }}">
16827 :root {
16828 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
16829 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16830 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16831 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16832 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
16833 }
16834 body.dark-theme {
16835 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
16836 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
16837 }
16838 *{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;}
16839 .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);}
16840 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16841 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
16842 .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));}
16843 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
16844 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
16845 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
16846 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16847 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16848 @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; } }
16849 .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;}
16850 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16851 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
16852 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16853 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16854 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16855 .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;}
16856 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16857 .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);}
16858 .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;}
16859 .settings-close:hover{color:var(--text);background:var(--surface-2);}
16860 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16861 .settings-modal-body{padding:14px 16px 16px;}
16862 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16863 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16864 .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;}
16865 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16866 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16867 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16868 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16869 .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;}
16870 .tz-select:focus{border-color:var(--oxide);}
16871 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
16872 .page-header{text-align:center;margin-bottom:16px;}
16873 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
16874 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
16875 /* Cards */
16876 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
16877 .option-card-wrap{position:relative;}
16878 .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;}
16879 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
16880 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
16881 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
16882 .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;}
16883 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
16884 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
16885 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
16886 .card-top-row{display:flex;align-items:center;gap:20px;}
16887 /* Two-column layout inside each card */
16888 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
16889 .card-left{display:flex;align-items:flex-start;min-width:0;}
16890 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
16891 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
16892 .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);}
16893 .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);}
16894 .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);}
16895 .card-text{min-width:0;}
16896 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
16897 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
16898 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
16899 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
16900 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
16901 /* Right CTA column */
16902 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
16903 .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;}
16904 /* Re-scan count badge */
16905 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
16906 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
16907 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
16908 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
16909 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
16910 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
16911 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
16912 body.dark-theme .btn-secondary{color:var(--oxide);}
16913 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
16914 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
16915 /* File input overlay — must be full-width so it aligns with other card-right buttons */
16916 .file-input-wrap{position:relative;width:100%;}
16917 .file-input-wrap .btn{width:100%;}
16918 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
16919 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16920 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16921 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16922 .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;}
16923 @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));}}
16924 /* Recent list (card 3 — full-width section below header) */
16925 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
16926 .recent-list{display:flex;flex-direction:column;gap:8px;}
16927 .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;}
16928 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
16929 .recent-item-info{flex:1;min-width:0;}
16930 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
16931 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
16932 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
16933 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
16934 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16935 .site-footer a{color:var(--muted);}
16936 @media(max-width:680px){
16937 .card-body{grid-template-columns:1fr;}
16938 .card-right{flex-direction:row;flex-wrap:wrap;}
16939 .btn{flex:1;}
16940 }
16941 .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;}
16942 .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;}
16943 .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;}
16944 </style>
16945</head>
16946<body>
16947 <div class="background-watermarks" aria-hidden="true">
16948 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16949 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16950 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16951 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16952 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16953 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16954 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16955 </div>
16956 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16957 <div class="top-nav">
16958 <div class="top-nav-inner">
16959 <a class="brand" href="/">
16960 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16961 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16962 </a>
16963 <div class="nav-right">
16964 <a class="nav-pill" href="/">Home</a>
16965 <div class="nav-dropdown">
16966 <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>
16967 <div class="nav-dropdown-menu">
16968 <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>
16969 </div>
16970 </div>
16971 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16972 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16973 <div class="nav-dropdown">
16974 <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>
16975 <div class="nav-dropdown-menu">
16976 <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>
16977 </div>
16978 </div>
16979 <div class="server-status-wrap" id="server-status-wrap">
16980 <div class="nav-pill server-online-pill" id="server-status-pill">
16981 <span class="status-dot" id="status-dot"></span>
16982 <span id="server-status-label">Server</span>
16983 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16984 </div>
16985 <div class="server-status-tip">
16986 OxideSLOC is running — accessible on your network.
16987 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16988 </div>
16989 </div>
16990 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16991 <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>
16992 </button>
16993 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16994 <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>
16995 <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>
16996 </button>
16997 </div>
16998 </div>
16999 </div>
17000
17001 <div class="page">
17002 <div class="page-header">
17003 <h1>How would you like to scan?</h1>
17004 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
17005 </div>
17006
17007 <div class="option-grid">
17008
17009 <!-- Option 1: New scan -->
17010 <div class="option-card-wrap">
17011 <div class="option-card">
17012 <div class="option-icon new-scan">
17013 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
17014 </div>
17015 <div class="card-body">
17016 <div class="card-left">
17017 <div class="card-text">
17018 <div class="option-title">Start a new scan</div>
17019 <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>
17020 <ul class="feature-list">
17021 <li>Live project scope preview before you run</li>
17022 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
17023 <li>HTML, PDF, and JSON output — your choice</li>
17024 </ul>
17025 </div>
17026 </div>
17027 <div class="card-right">
17028 <a class="btn btn-primary" href="/scan">
17029 Configure & scan
17030 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
17031 </a>
17032 <p class="card-tip">Full 4-step setup · all options</p>
17033 </div>
17034 </div>
17035 </div>
17036 </div>
17037
17038 <!-- Option 2: Load from config file -->
17039 <div class="option-card-wrap">
17040 <div class="option-card">
17041 <div class="option-icon load-config">
17042 <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>
17043 </div>
17044 <div class="card-body">
17045 <div class="card-left">
17046 <div class="card-text">
17047 <div class="option-title">Load a saved config</div>
17048 <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>
17049 <ul class="feature-list">
17050 <li>All 15 settings restored from the file</li>
17051 <li>Fully editable — change path or output dir</li>
17052 <li>Works with any scan-config.json</li>
17053 </ul>
17054 </div>
17055 </div>
17056 <div class="card-right">
17057 <div class="file-input-wrap">
17058 <button class="btn btn-secondary" id="load-config-btn" type="button">
17059 <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>
17060 Choose config file
17061 </button>
17062 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
17063 </div>
17064 <p class="card-tip" id="config-file-name">Exported after every scan</p>
17065 </div>
17066 </div>
17067 </div>
17068 </div>
17069
17070 <!-- Option 3: Re-scan recent project -->
17071 <div class="option-card-wrap">
17072 <div class="option-card" id="recent-card">
17073 <div class="card-top-row">
17074 <div class="option-icon rescan">
17075 <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>
17076 </div>
17077 <div class="card-body">
17078 <div class="card-left">
17079 <div class="card-text">
17080 <div class="option-title">Re-scan a recent project</div>
17081 <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>
17082 <ul class="feature-list">
17083 <li>All 15+ settings restored from the saved config</li>
17084 <li>Path and output dir are editable before running</li>
17085 <li>Only scans with a saved config appear here</li>
17086 </ul>
17087 </div>
17088 </div>
17089 <div class="card-right">
17090 <div class="rescan-count-box">
17091 <div class="rescan-count-num" id="rescan-count-num">—</div>
17092 <div class="rescan-count-label">saved configs</div>
17093 </div>
17094 <a class="btn btn-secondary" href="/view-reports">
17095 <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>
17096 View all runs
17097 </a>
17098 <p class="card-tip">Opens run history</p>
17099 </div>
17100 </div>
17101 </div>
17102 <div class="section-divider"></div>
17103 <div class="recent-list" id="recent-list">
17104 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
17105 </div>
17106 </div>
17107 </div>
17108
17109 </div>
17110 </div>
17111
17112 <footer class="site-footer">
17113 local code analysis - metrics, history and reports
17114 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17115 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17116 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17117 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17118 · <a href="/api-docs" rel="noopener">REST API</a>
17119 </footer>
17120
17121 <script nonce="{{ csp_nonce }}">
17122 (function () {
17123 var storageKey = 'oxide-sloc-theme';
17124 var body = document.body;
17125 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17126 var toggle = document.getElementById('theme-toggle');
17127 if (toggle) toggle.addEventListener('click', function () {
17128 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17129 body.classList.toggle('dark-theme', next === 'dark');
17130 try { localStorage.setItem(storageKey, next); } catch(e) {}
17131 });
17132
17133 (function randomizeWatermarks() {
17134 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17135 if (!wms.length) return;
17136 var placed = [];
17137 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; }
17138 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]; }
17139 var half = Math.floor(wms.length / 2);
17140 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; });
17141 })();
17142 (function spawnCodeParticles() {
17143 var container = document.getElementById('code-particles');
17144 if (!container) return;
17145 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'];
17146 var count = 38;
17147 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); }
17148 })();
17149 // Recent scans data injected from server
17150 var recentScans = {{ recent_scans_json|safe }};
17151
17152 function configToParams(cfg) {
17153 var p = new URLSearchParams();
17154 p.set('prefilled', '1');
17155 if (cfg.path) p.set('path', cfg.path);
17156 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
17157 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
17158 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
17159 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
17160 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
17161 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
17162 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
17163 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
17164 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
17165 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
17166 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
17167 if (cfg.report_title) p.set('report_title', cfg.report_title);
17168 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
17169 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
17170 return p;
17171 }
17172
17173 // Build recent scan list (capped at 3 visible entries)
17174 var list = document.getElementById('recent-list');
17175 var noNote = document.getElementById('no-recent-note');
17176 var hasAny = false;
17177 var MAX_RECENT = 3;
17178 if (Array.isArray(recentScans)) {
17179 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
17180 var shown = 0;
17181 validEntries.forEach(function (entry) {
17182 if (shown >= MAX_RECENT) return;
17183 shown++;
17184 hasAny = true;
17185 var item = document.createElement('div');
17186 item.className = 'recent-item';
17187 item.title = 'Restore all settings and open wizard';
17188 item.innerHTML =
17189 '<div class="recent-item-info">' +
17190 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
17191 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
17192 '</div>' +
17193 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
17194 item.addEventListener('click', function () {
17195 var params = configToParams(entry.config);
17196 window.location.href = '/scan?' + params.toString();
17197 });
17198 list.appendChild(item);
17199 });
17200 if (validEntries.length > MAX_RECENT) {
17201 var moreEl = document.createElement('div');
17202 moreEl.className = 'recent-more-link';
17203 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
17204 list.appendChild(moreEl);
17205 }
17206 }
17207 if (hasAny && noNote) noNote.style.display = 'none';
17208 // Update count badge
17209 var countEl = document.getElementById('rescan-count-num');
17210 if (countEl) {
17211 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
17212 countEl.textContent = total > 0 ? total : '0';
17213 }
17214
17215 // Config file loader
17216 var fileInput = document.getElementById('config-file-input');
17217 var fileName = document.getElementById('config-file-name');
17218 var loadBtn = document.getElementById('load-config-btn');
17219 // Wire the visible button to open the hidden file picker.
17220 if (loadBtn && fileInput) {
17221 loadBtn.addEventListener('click', function () { fileInput.click(); });
17222 }
17223 if (fileInput) {
17224 fileInput.addEventListener('change', function () {
17225 var file = fileInput.files && fileInput.files[0];
17226 if (!file) return;
17227 if (fileName) fileName.textContent = '✓ ' + file.name;
17228 var reader = new FileReader();
17229 reader.onload = function (e) {
17230 try {
17231 var cfg = JSON.parse(e.target.result);
17232 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
17233 var params = configToParams(cfg);
17234 window.location.href = '/scan?' + params.toString();
17235 } catch (err) {
17236 alert('Could not parse config file: ' + err.message);
17237 }
17238 };
17239 reader.readAsText(file);
17240 });
17241 }
17242
17243 function escHtml(s) {
17244 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
17245 }
17246 })();
17247 </script>
17248 <script nonce="{{ csp_nonce }}">
17249 (function(){
17250 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'}];
17251 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);});}
17252 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17253 function init(){
17254 var btn=document.getElementById('settings-btn');if(!btn)return;
17255 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17256 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>';
17257 document.body.appendChild(m);
17258 var g=document.getElementById('scheme-grid');
17259 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);});
17260 var cl=document.getElementById('settings-close');
17261 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);
17262 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');});
17263 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17264 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17265 }
17266 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17267 }());
17268 </script>
17269 <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>
17270</body>
17271</html>
17272"##,
17273 ext = "html"
17274)]
17275struct ScanSetupTemplate {
17276 version: &'static str,
17277 recent_scans_json: String,
17278 csp_nonce: String,
17279}
17280
17281#[derive(Template)]
17282#[template(
17283 source = r##"
17284<!doctype html>
17285<html lang="en">
17286<head>
17287 <meta charset="utf-8">
17288 <meta name="viewport" content="width=device-width, initial-scale=1">
17289 <title>OxideSLOC | {{ report_title }} | Report</title>
17290 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17291 <style nonce="{{ csp_nonce }}">
17292 :root {
17293 --radius: 18px;
17294 --bg: #f5efe8;
17295 --surface: rgba(255,255,255,0.82);
17296 --surface-2: #fbf7f2;
17297 --surface-3: #efe6dc;
17298 --line: #e6d0bf;
17299 --line-strong: #dcb89f;
17300 --text: #43342d;
17301 --muted: #7b675b;
17302 --muted-2: #a08777;
17303 --nav: #b85d33;
17304 --nav-2: #7a371b;
17305 --accent: #6f9bff;
17306 --accent-2: #4a78ee;
17307 --oxide: #d37a4c;
17308 --oxide-2: #b35428;
17309 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
17310 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
17311 --success-bg: #e8f5ed;
17312 --success-text: #1a8f47;
17313 --info-bg: #eef3ff;
17314 --info-text: #4467d8;
17315 }
17316
17317 body.dark-theme {
17318 --bg: #1b1511;
17319 --surface: #261c17;
17320 --surface-2: #2d221d;
17321 --surface-3: #372922;
17322 --line: #524238;
17323 --line-strong: #6c5649;
17324 --text: #f5ece6;
17325 --muted: #c7b7aa;
17326 --muted-2: #aa9485;
17327 --nav: #b85d33;
17328 --nav-2: #7a371b;
17329 --accent: #6f9bff;
17330 --accent-2: #4a78ee;
17331 --oxide: #d37a4c;
17332 --oxide-2: #b35428;
17333 --shadow: 0 18px 42px rgba(0,0,0,0.28);
17334 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
17335 --success-bg: #163927;
17336 --success-text: #8fe2a8;
17337 --info-bg: #1c2847;
17338 --info-text: #a9c1ff;
17339 }
17340
17341 * { box-sizing: border-box; }
17342 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); }
17343 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17344 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17345 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17346 .top-nav, .page { position: relative; z-index: 2; }
17347 .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); }
17348 .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; }
17349 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17350 .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)); }
17351 .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; }
17352 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17353 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17354 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17355 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17356 .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; }
17357 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17358 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17359 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17360 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17361 @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; } }
17362 .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; }
17363 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17364 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17365 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17366 .theme-toggle .icon-sun { display:none; }
17367 body.dark-theme .theme-toggle .icon-sun { display:block; }
17368 body.dark-theme .theme-toggle .icon-moon { display:none; }
17369 .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;}
17370 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17371 .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);}
17372 .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;}
17373 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17374 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17375 .settings-modal-body{padding:14px 16px 16px;}
17376 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17377 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17378 .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;}
17379 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17380 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17381 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17382 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17383 .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;}
17384 .tz-select:focus{border-color:var(--oxide);}
17385 .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; }
17386 .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;}
17387 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
17388 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
17389 .hero, .panel { padding: 22px; }
17390 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
17391 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17392 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
17393 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
17394 .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; }
17395 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
17396 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
17397 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
17398 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
17399 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
17400 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
17401 .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; }
17402 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
17403 .delta-card-val { font-size:16px; font-weight:800; }
17404 .delta-card-val.pos { color:#1e7e34; }
17405 .delta-card-val.neg { color:var(--neg); }
17406 .delta-card-val.mod { color:#b35428; }
17407 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
17408 .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; }
17409 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17410 .delta-card-inline:hover .delta-card-tip { opacity:1; }
17411 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
17412 .compare-ts { font-size:13px; color:var(--muted); }
17413 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
17414 .compare-arrow { color: var(--muted); }
17415 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
17416 .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; }
17417 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
17418 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
17419 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
17420 .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; }
17421 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
17422 .run-mgmt-card .action-buttons { justify-content:center; }
17423 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
17424 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
17425 .button, .copy-button {
17426 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;
17427 }
17428 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
17429 @keyframes spin { to { transform: rotate(360deg); } }
17430 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
17431 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
17432 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
17433 .path-item strong { display: block; margin-bottom: 6px; }
17434 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
17435 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
17436 .path-subitem { flex: 1; }
17437 .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); }
17438 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); }
17439 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
17440 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
17441 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
17442 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
17443 th { color: var(--muted); font-weight: 700; }
17444 tr:last-child td { border-bottom: none; }
17445 #subm-tbl col:nth-child(1){width:15%;}
17446 #subm-tbl col:nth-child(2){width:31%;}
17447 #subm-tbl col:nth-child(3){width:9%;}
17448 #subm-tbl col:nth-child(4){width:9%;}
17449 #subm-tbl col:nth-child(5){width:9%;}
17450 #subm-tbl col:nth-child(6){width:9%;}
17451 #subm-tbl col:nth-child(7){width:9%;}
17452 #subm-tbl col:nth-child(8){width:9%;}
17453 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
17454 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
17455 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
17456 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
17457 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
17458 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
17459 .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; }
17460 .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; }
17461 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
17462 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
17463 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
17464 .muted { color: var(--muted); }
17465 /* Run-ID chip row (mirrors HTML report) */
17466 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
17467 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
17468 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
17469 .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; }
17470 .run-id-chip[data-copy] { cursor:pointer; }
17471 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
17472 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
17473 .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; }
17474 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
17475 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17476 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
17477 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
17478 a.commit-link-value { color:inherit; text-decoration:none; }
17479 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
17480 .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; }
17481 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17482 .run-id-chip:hover .chip-tooltip { opacity:1; }
17483 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
17484 .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; }
17485 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
17486 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
17487 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
17488 /* Meta chips row */
17489 .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%; }
17490 .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; }
17491 .meta-chip:last-child { border-right:none; }
17492 .meta-chip b { color:var(--text); font-weight:700; }
17493 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17494 .site-footer a{color:var(--muted);}
17495 .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; }
17496 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
17497 .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; }
17498 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
17499 /* Stat chips (matches HTML report) */
17500 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
17501 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
17502 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
17503 .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; }
17504 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
17505 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
17506 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
17507 .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; }
17508 .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; }
17509 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17510 .stat-chip:hover .stat-chip-tip { opacity:1; }
17511 /* Submodule panel */
17512 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
17513 /* Metrics tables stack */
17514 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
17515 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
17516 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
17517 .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)); }
17518 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
17519 /* Metrics table */
17520 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
17521 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
17522 .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; }
17523 .metrics-table thead th:not(:first-child) { text-align: right; }
17524 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
17525 .metrics-table tbody tr:last-child td { border-bottom: none; }
17526 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
17527 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
17528 .metrics-table tbody tr:hover td { background: var(--surface-2); }
17529 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
17530 .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; }
17531 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
17532 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
17533 .mt-val-pos { color: var(--pos); font-weight: 700; }
17534 .mt-val-neg { color: var(--neg); font-weight: 700; }
17535 .mt-val-zero { color: var(--muted); }
17536 .mt-val-mod { color: var(--oxide-2); }
17537 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
17538 @media (max-width: 1180px) {
17539 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
17540 .nav-project-slot, .nav-status { justify-content:flex-start; }
17541 .hero-top { flex-direction: column; }
17542 .run-mgmt-strip { flex-direction: column; }
17543 }
17544 .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;}
17545 @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));}}
17546 .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;}
17547 /* ── Result-page chart controls ─────────────────────────────────────────── */
17548 .r-chart-section{margin-bottom:24px;}
17549 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
17550 .section-pair > .panel{flex-shrink:0;}
17551 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
17552 .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;}
17553 .r-chart-select:focus{border-color:var(--accent);}
17554 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
17555 .r-chart-container svg{display:block;width:100%;height:auto;}
17556 .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;}
17557 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
17558 .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;}
17559 .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);}
17560 .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;}
17561 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
17562 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
17563 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
17564 .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;}
17565 .r-chart-modal-close:hover{opacity:.7;}
17566 body.dark-theme .r-chart-modal{background:var(--surface);}
17567 .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;}
17568 .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);}
17569 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
17570 .lang-bar-row:hover{transform:translateY(-2px);}
17571 .lang-bar-row .rchit:hover{filter:none;transform:none;}
17572 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
17573 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
17574 .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;}
17575 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
17576 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
17577 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
17578 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
17579 #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;}
17580 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
17581 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
17582 .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;}
17583 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
17584 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
17585 .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;}
17586 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
17587 .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;}
17588 .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;}
17589 body.has-report-banner .top-nav{top:27px;}
17590 body.has-report-banner{padding-bottom:27px;}
17591 </style>
17592</head>
17593<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
17594 <div class="background-watermarks" aria-hidden="true">
17595 <img src="/images/logo/logo-text.png" alt="" />
17596 <img src="/images/logo/logo-text.png" alt="" />
17597 <img src="/images/logo/logo-text.png" alt="" />
17598 <img src="/images/logo/logo-text.png" alt="" />
17599 <img src="/images/logo/logo-text.png" alt="" />
17600 <img src="/images/logo/logo-text.png" alt="" />
17601 <img src="/images/logo/logo-text.png" alt="" />
17602 <img src="/images/logo/logo-text.png" alt="" />
17603 <img src="/images/logo/logo-text.png" alt="" />
17604 <img src="/images/logo/logo-text.png" alt="" />
17605 <img src="/images/logo/logo-text.png" alt="" />
17606 <img src="/images/logo/logo-text.png" alt="" />
17607 <img src="/images/logo/logo-text.png" alt="" />
17608 <img src="/images/logo/logo-text.png" alt="" />
17609 </div>
17610 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17611 {% if let Some(banner) = report_header_footer %}
17612 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
17613 {% endif %}
17614 <div class="top-nav">
17615 <div class="top-nav-inner">
17616 <a class="brand" href="/">
17617 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17618 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
17619 </a>
17620 <div class="nav-project-slot">
17621 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
17622 </div>
17623 <div class="nav-status">
17624 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
17625 <div class="nav-dropdown">
17626 <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>
17627 <div class="nav-dropdown-menu">
17628 <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>
17629 </div>
17630 </div>
17631 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
17632 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17633 <div class="nav-dropdown">
17634 <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>
17635 <div class="nav-dropdown-menu">
17636 <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>
17637 </div>
17638 </div>
17639 <div class="server-status-wrap" id="server-status-wrap">
17640 <div class="nav-pill server-online-pill" id="server-status-pill">
17641 <span class="status-dot" id="status-dot"></span>
17642 <span id="server-status-label">Server</span>
17643 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17644 </div>
17645 <div class="server-status-tip">
17646 OxideSLOC is running — accessible on your network.
17647 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17648 </div>
17649 </div>
17650 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17651 <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>
17652 </button>
17653 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17654 <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>
17655 <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>
17656 </button>
17657 </div>
17658 </div>
17659 </div>
17660
17661 <div class="page">
17662 <section class="hero">
17663 <div class="hero-top">
17664 <div>
17665 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
17666 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
17667 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
17668 <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>
17669 </div>
17670 </div>
17671 <div class="hero-quick-actions">
17672 {% if server_mode %}
17673 <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>
17674 {% else %}
17675 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
17676 {% endif %}
17677 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
17678 {% if !server_mode %}
17679 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
17680 {% endif %}
17681 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
17682 <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>
17683 </div>
17684 </div>
17685
17686 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
17687 <div class="run-id-row">
17688 <span class="run-id-chip" data-copy="{{ run_id }}">
17689 <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>
17690 <span class="run-id-chip-value">{{ run_id }}</span>
17691 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
17692 </span>
17693 {% match git_commit_long %}
17694 {% when Some with (long_sha) %}
17695 {% match git_commit_url %}
17696 {% when Some with (commit_url) %}
17697 <span class="run-id-chip" data-copy="{{ long_sha }}">
17698 <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>
17699 <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
17700 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
17701 </span>
17702 {% when None %}
17703 <span class="run-id-chip" data-copy="{{ long_sha }}">
17704 <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>
17705 <span class="run-id-chip-value">{{ long_sha }}</span>
17706 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
17707 </span>
17708 {% endmatch %}
17709 {% when None %}
17710 <span class="run-id-chip muted-chip">
17711 <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>
17712 <span class="run-id-chip-value">Not detected</span>
17713 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
17714 </span>
17715 {% endmatch %}
17716 {% match git_branch %}
17717 {% when Some with (branch) %}
17718 <span class="run-id-chip">
17719 <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>
17720 <span class="run-id-chip-value">{{ branch }}</span>
17721 <span class="chip-tooltip">Git branch active at scan time</span>
17722 </span>
17723 {% when None %}
17724 <span class="run-id-chip muted-chip">
17725 <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>
17726 <span class="run-id-chip-value">Not detected</span>
17727 <span class="chip-tooltip">No Git branch was found for this scan</span>
17728 </span>
17729 {% endmatch %}
17730 {% match git_author %}
17731 {% when Some with (author) %}
17732 <span class="run-id-chip" data-author="{{ author }}">
17733 <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>
17734 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
17735 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
17736 </span>
17737 {% when None %}
17738 <span class="run-id-chip muted-chip">
17739 <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>
17740 <span class="run-id-chip-value">Not detected</span>
17741 <span class="chip-tooltip">No commit author was found for this scan</span>
17742 </span>
17743 {% endmatch %}
17744 </div>
17745
17746 <!-- Scan metadata row -->
17747 <div class="meta">
17748 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
17749 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
17750 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
17751 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
17752 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
17753 </div>
17754
17755 <!-- 12 summary stat chips -->
17756 <div class="summary-strip">
17757 <div class="stat-chip" data-raw="{{ physical_lines }}">
17758 <div class="stat-chip-label">Physical lines</div>
17759 <div class="stat-chip-val">{{ physical_lines }}</div>
17760 <div class="stat-chip-exact"></div>
17761 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
17762 </div>
17763 <div class="stat-chip" data-raw="{{ code_lines }}">
17764 <div class="stat-chip-label">Code</div>
17765 <div class="stat-chip-val">{{ code_lines }}</div>
17766 <div class="stat-chip-exact"></div>
17767 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
17768 </div>
17769 <div class="stat-chip" data-raw="{{ comment_lines }}">
17770 <div class="stat-chip-label">Comments</div>
17771 <div class="stat-chip-val">{{ comment_lines }}</div>
17772 <div class="stat-chip-exact"></div>
17773 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
17774 </div>
17775 <div class="stat-chip" data-raw="{{ blank_lines }}">
17776 <div class="stat-chip-label">Blank</div>
17777 <div class="stat-chip-val">{{ blank_lines }}</div>
17778 <div class="stat-chip-exact"></div>
17779 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
17780 </div>
17781 <div class="stat-chip" data-raw="{{ mixed_lines }}">
17782 <div class="stat-chip-label">Mixed separate</div>
17783 <div class="stat-chip-val">{{ mixed_lines }}</div>
17784 <div class="stat-chip-exact"></div>
17785 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
17786 </div>
17787 <div class="stat-chip" data-raw="{{ functions }}">
17788 <div class="stat-chip-label">Functions</div>
17789 <div class="stat-chip-val">{{ functions }}</div>
17790 <div class="stat-chip-exact"></div>
17791 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
17792 </div>
17793 <div class="stat-chip" data-raw="{{ classes }}">
17794 <div class="stat-chip-label">Classes / Types</div>
17795 <div class="stat-chip-val">{{ classes }}</div>
17796 <div class="stat-chip-exact"></div>
17797 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
17798 </div>
17799 <div class="stat-chip" data-raw="{{ variables }}">
17800 <div class="stat-chip-label">Variables</div>
17801 <div class="stat-chip-val">{{ variables }}</div>
17802 <div class="stat-chip-exact"></div>
17803 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
17804 </div>
17805 <div class="stat-chip" data-raw="{{ imports }}">
17806 <div class="stat-chip-label">Imports</div>
17807 <div class="stat-chip-val">{{ imports }}</div>
17808 <div class="stat-chip-exact"></div>
17809 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
17810 </div>
17811 <div class="stat-chip" data-raw="{{ test_count }}">
17812 <div class="stat-chip-label">Tests</div>
17813 <div class="stat-chip-val">{{ test_count }}</div>
17814 <div class="stat-chip-exact"></div>
17815 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
17816 </div>
17817 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
17818 <div class="stat-chip-label">Code density</div>
17819 <div class="stat-chip-val stat-chip-density-val">—</div>
17820 <div class="stat-chip-exact"></div>
17821 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
17822 </div>
17823 <div class="stat-chip" data-raw="{{ files_analyzed }}">
17824 <div class="stat-chip-label">Files analyzed</div>
17825 <div class="stat-chip-val">{{ files_analyzed }}</div>
17826 <div class="stat-chip-exact"></div>
17827 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
17828 </div>
17829 </div>
17830
17831 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
17832 <div class="compare-banner">
17833 <div class="compare-banner-body">
17834 <div class="compare-banner-meta">
17835 <span class="compare-label">Previous scan</span>
17836 <span class="compare-ts">{{ prev_ts }}</span>
17837 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
17838 {% if let Some(prev_code) = prev_run_code_lines %}
17839 <div class="compare-banner-stats" style="margin-top:4px;">
17840 <span>Code before: <strong>{{ prev_code }}</strong></span>
17841 <span class="compare-arrow">→</span>
17842 <span>Code now: <strong>{{ code_lines }}</strong></span>
17843 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
17844 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
17845 </div>
17846 {% endif %}
17847 </div>
17848 {% if delta_lines_added.is_some() %}
17849 <div class="delta-cards-inline">
17850 <div class="delta-card-inline">
17851 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
17852 <div class="delta-card-lbl">lines added</div>
17853 <div class="delta-card-tip">Code lines added since the previous scan</div>
17854 </div>
17855 <div class="delta-card-inline">
17856 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
17857 <div class="delta-card-lbl">lines removed</div>
17858 <div class="delta-card-tip">Code lines removed since the previous scan</div>
17859 </div>
17860 <div class="delta-card-inline">
17861 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
17862 <div class="delta-card-lbl">unmodified lines</div>
17863 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
17864 </div>
17865 <div class="delta-card-inline">
17866 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
17867 <div class="delta-card-lbl">files modified</div>
17868 <div class="delta-card-tip">Files with at least one line changed</div>
17869 </div>
17870 <div class="delta-card-inline">
17871 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
17872 <div class="delta-card-lbl">files added</div>
17873 <div class="delta-card-tip">New files added since the previous scan</div>
17874 </div>
17875 <div class="delta-card-inline">
17876 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
17877 <div class="delta-card-lbl">files removed</div>
17878 <div class="delta-card-tip">Files deleted since the previous scan</div>
17879 </div>
17880 <div class="delta-card-inline">
17881 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
17882 <div class="delta-card-lbl">files unchanged</div>
17883 <div class="delta-card-tip">Files with no changes since the previous scan</div>
17884 </div>
17885 </div>
17886 {% else %}
17887 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
17888 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
17889 </p>
17890 {% endif %}
17891 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
17892 </div>
17893 </div>
17894 {% endif %}{% endif %}
17895
17896 <div class="action-grid">
17897 <div class="action-card">
17898 <h3>HTML report</h3>
17899 <div class="action-buttons">
17900 {% match html_url %}
17901 {% when Some with (url) %}
17902 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
17903 {% when None %}{% endmatch %}
17904 {% match html_download_url %}
17905 {% when Some with (url) %}
17906 <a class="button secondary" href="{{ url }}">Download HTML</a>
17907 {% when None %}{% endmatch %}
17908 {% match html_path %}
17909 {% when Some with (_path) %}{% when None %}{% endmatch %}
17910 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
17911 </div>
17912 </div>
17913 <div class="action-card">
17914 <h3>PDF report</h3>
17915 <div class="action-buttons">
17916 {% match pdf_url %}
17917 {% when Some with (url) %}
17918 {% if pdf_generating %}
17919 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
17920 <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>
17921 Generating PDF…
17922 </button>
17923 {% else %}
17924 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
17925 {% endif %}
17926 {% when None %}
17927 {% match html_url %}
17928 {% when Some with (_hurl) %}
17929 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
17930 <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>
17931 {% when None %}
17932 <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;">
17933 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
17934 </p>
17935 {% endmatch %}
17936 {% endmatch %}
17937 {% match pdf_download_url %}
17938 {% when Some with (url) %}
17939 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
17940 {% when None %}{% endmatch %}
17941 {% match pdf_url %}
17942 {% when Some with (_) %}
17943 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
17944 {% when None %}{% endmatch %}
17945 </div>
17946 </div>
17947 <div class="action-card">
17948 <h3>JSON result</h3>
17949 <div class="action-buttons">
17950 {% match json_url %}
17951 {% when Some with (url) %}
17952 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
17953 {% when None %}{% endmatch %}
17954 {% match json_download_url %}
17955 {% when Some with (url) %}
17956 <a class="button secondary" href="{{ url }}">Download JSON</a>
17957 {% when None %}{% endmatch %}
17958 {% match json_path %}
17959 {% when Some with (_path) %}
17960 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
17961 {% when None %}
17962 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
17963 {% endmatch %}
17964 </div>
17965 </div>
17966 <div class="action-card">
17967 <h3>Scan config</h3>
17968 <div class="action-buttons">
17969 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
17970 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
17971 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
17972 </div>
17973 </div>
17974 {% if confluence_configured %}
17975 <div class="action-card" id="confluenceCard">
17976 <h3>Confluence</h3>
17977 <div class="action-buttons">
17978 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
17979 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
17980 </div>
17981 <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>
17982 </div>
17983 {% endif %}
17984 </div>
17985 {% if confluence_configured %}
17986 <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;">
17987 <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);">
17988 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
17989 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
17990 <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;">
17991 <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>
17992 <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;">
17993 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
17994 <div style="display:flex;gap:10px;justify-content:flex-end;">
17995 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
17996 <button class="button" id="confSubmitBtn" type="button">Post</button>
17997 </div>
17998 </div>
17999 </div>
18000 {% endif %}
18001 <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;">
18002 <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);">
18003 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
18004 <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>
18005 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
18006 <div style="display:flex;gap:18px;justify-content:flex-end;">
18007 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
18008 <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>
18009 </div>
18010 </div>
18011 </div>
18012 {% if !submodule_rows.is_empty() %}
18013 <div class="submodule-panel">
18014 <div class="toolbar-row">
18015 <div>
18016 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
18017 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
18018 </div>
18019 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
18020 </div>
18021 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
18022 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
18023 <colgroup><col style="width:15%"><col style="width:31%"><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>
18024 <thead>
18025 <tr>
18026 <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>
18027 <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>
18028 <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>
18029 <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>
18030 <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>
18031 <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>
18032 <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>
18033 <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>
18034 </tr>
18035 </thead>
18036 <tbody>
18037 {% for row in submodule_rows %}
18038 <tr>
18039 <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>
18040 <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>
18041 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
18042 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
18043 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
18044 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
18045 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
18046 <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>
18047 </tr>
18048 {% endfor %}
18049 </tbody>
18050 </table>
18051 </div>
18052 </div>
18053 {% endif %}
18054
18055 <div class="metrics-tables-stack">
18056
18057 <div class="metrics-table-wrap">
18058 <div class="metrics-table-title">Files</div>
18059 <table class="metrics-table">
18060 <thead>
18061 <tr>
18062 <th>Metric</th>
18063 <th>This Run</th>
18064 <th>Previous</th>
18065 <th>Change</th>
18066 </tr>
18067 </thead>
18068 <tbody>
18069 <tr>
18070 <td>Files analyzed</td>
18071 <td class="mt-val-large">{{ files_analyzed }}</td>
18072 <td>{{ prev_fa_str }}</td>
18073 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
18074 </tr>
18075 <tr>
18076 <td>Files skipped</td>
18077 <td>{{ files_skipped }}</td>
18078 <td>{{ prev_fs_str }}</td>
18079 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
18080 </tr>
18081 <tr>
18082 <td>Files modified</td>
18083 <td class="mt-val-na">—</td>
18084 <td class="mt-val-na">—</td>
18085 <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>
18086 </tr>
18087 <tr>
18088 <td>Files unchanged</td>
18089 <td class="mt-val-na">—</td>
18090 <td class="mt-val-na">—</td>
18091 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
18092 </tr>
18093 </tbody>
18094 </table>
18095 </div>
18096
18097 <div class="metrics-table-wrap">
18098 <div class="metrics-table-title">Line Counts</div>
18099 <table class="metrics-table">
18100 <thead>
18101 <tr>
18102 <th>Metric</th>
18103 <th>This Run</th>
18104 <th>Previous</th>
18105 <th>Change</th>
18106 </tr>
18107 </thead>
18108 <tbody>
18109 <tr>
18110 <td>Physical lines</td>
18111 <td class="mt-val-large">{{ physical_lines }}</td>
18112 <td>{{ prev_pl_str }}</td>
18113 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
18114 </tr>
18115 <tr>
18116 <td>Code lines</td>
18117 <td class="mt-val-large">{{ code_lines }}</td>
18118 <td>{{ prev_cl_str }}</td>
18119 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
18120 </tr>
18121 <tr>
18122 <td>Comment lines</td>
18123 <td>{{ comment_lines }}</td>
18124 <td>{{ prev_cml_str }}</td>
18125 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
18126 </tr>
18127 <tr>
18128 <td>Blank lines</td>
18129 <td>{{ blank_lines }}</td>
18130 <td>{{ prev_bl_str }}</td>
18131 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
18132 </tr>
18133 <tr>
18134 <td>Mixed (separate)</td>
18135 <td>{{ mixed_lines }}</td>
18136 <td class="mt-val-na">—</td>
18137 <td class="mt-val-na">—</td>
18138 </tr>
18139 </tbody>
18140 </table>
18141 </div>
18142
18143 <div class="metrics-tables-lower">
18144 <div class="metrics-table-wrap">
18145 <div class="metrics-table-title">Code Structure</div>
18146 <table class="metrics-table">
18147 <thead>
18148 <tr>
18149 <th>Metric</th>
18150 <th>This Run</th>
18151 </tr>
18152 </thead>
18153 <tbody>
18154 <tr>
18155 <td>Functions</td>
18156 <td>{{ functions }}</td>
18157 </tr>
18158 <tr>
18159 <td>Classes / Types</td>
18160 <td>{{ classes }}</td>
18161 </tr>
18162 <tr>
18163 <td>Variables</td>
18164 <td>{{ variables }}</td>
18165 </tr>
18166 <tr>
18167 <td>Imports</td>
18168 <td>{{ imports }}</td>
18169 </tr>
18170 </tbody>
18171 </table>
18172 </div>
18173
18174 <div class="metrics-table-wrap">
18175 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
18176 <table class="metrics-table">
18177 <thead>
18178 <tr>
18179 <th>Metric</th>
18180 <th>Change</th>
18181 </tr>
18182 </thead>
18183 <tbody>
18184 <tr>
18185 <td>Lines added</td>
18186 <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>
18187 </tr>
18188 <tr>
18189 <td>Lines removed</td>
18190 <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>
18191 </tr>
18192 <tr>
18193 <td>Lines modified (net)</td>
18194 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
18195 </tr>
18196 <tr>
18197 <td>Lines unmodified</td>
18198 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
18199 </tr>
18200 </tbody>
18201 </table>
18202 </div>
18203 </div>
18204
18205 </div>
18206
18207 <div class="path-list">
18208 <div class="path-item">
18209 <div class="path-item-label">Project path</div>
18210 <code>{{ project_path }}</code>
18211 </div>
18212 <div class="path-item">
18213 <div class="path-item-label">Git branch</div>
18214 {% if let Some(branch) = git_branch %}
18215 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
18216 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
18217 {% else %}
18218 <code style="color:var(--muted)">—</code>
18219 {% endif %}
18220 </div>
18221 <div class="path-item">
18222 <div class="path-item-label">Output folder</div>
18223 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
18224 </div>
18225 <div class="path-item">
18226 <div class="path-item-label">Run ID</div>
18227 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
18228 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
18229 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
18230 </div>
18231 </div>
18232 </div>
18233 </section>
18234
18235 <div class="section-pair">
18236 <section class="panel">
18237 <div class="toolbar-row">
18238 <div>
18239 <h2>Language breakdown</h2>
18240 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
18241 </div>
18242 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18243 </div>
18244 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
18245 </section>
18246
18247 <section class="panel r-chart-section">
18248 <div class="toolbar-row" style="margin-bottom:16px;">
18249 <div>
18250 <h2>Visualizations</h2>
18251 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
18252 </div>
18253 </div>
18254
18255 <div class="r-viz-grid">
18256 <div class="r-viz-card">
18257 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18258 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
18259 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18260 </div>
18261 <div class="r-chart-tab-bar">
18262 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
18263 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
18264 </div>
18265 <div class="r-chart-container" id="r-composition-chart"></div>
18266 </div>
18267 <div class="r-viz-card">
18268 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18269 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
18270 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18271 </div>
18272 <div class="r-chart-container" id="r-scatter-chart"></div>
18273 </div>
18274 {% if has_semantic_data %}
18275 <div class="r-viz-card">
18276 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18277 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
18278 <select class="r-chart-select" id="r-semantic-metric">
18279 <option value="functions">Functions</option>
18280 <option value="classes">Classes</option>
18281 <option value="variables">Variables</option>
18282 <option value="imports">Imports</option>
18283 <option value="tests">Tests</option>
18284 </select>
18285 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18286 </div>
18287 <div class="r-chart-container" id="r-semantic-chart"></div>
18288 </div>
18289 {% endif %}
18290 <div class="r-viz-card">
18291 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18292 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
18293 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18294 </div>
18295 <div class="r-chart-container" id="r-density-chart"></div>
18296 </div>
18297 <div class="r-viz-card">
18298 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18299 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
18300 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18301 </div>
18302 <div class="r-chart-container" id="r-avglines-chart"></div>
18303 </div>
18304 <div class="r-viz-card">
18305 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
18306 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
18307 <select class="r-chart-select" id="r-sub-metric">
18308 <option value="code">Code Lines</option>
18309 <option value="comment">Comments</option>
18310 <option value="blank">Blank Lines</option>
18311 <option value="physical">Physical Lines</option>
18312 <option value="files">Files</option>
18313 </select>
18314 <select class="r-chart-select" id="r-sub-sort">
18315 <option value="desc">Value ↓</option>
18316 <option value="asc">Value ↑</option>
18317 <option value="name">Name A→Z</option>
18318 </select>
18319 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
18320 </div>
18321 <div class="r-chart-container" id="r-submodule-chart"></div>
18322 </div>
18323 </div>
18324
18325 </section>
18326 </div>
18327
18328 </div>
18329
18330 <div id="r-tt" aria-hidden="true"></div>
18331
18332 <script nonce="{{ csp_nonce }}">
18333 (function () {
18334 var body = document.body;
18335 var themeToggle = document.getElementById('theme-toggle');
18336 var storageKey = 'oxide-sloc-theme';
18337
18338 function applyTheme(theme) {
18339 body.classList.toggle('dark-theme', theme === 'dark');
18340 }
18341
18342 function loadSavedTheme() {
18343 try {
18344 var saved = localStorage.getItem(storageKey);
18345 if (saved === 'dark' || saved === 'light') {
18346 applyTheme(saved);
18347 }
18348 } catch (e) {}
18349 }
18350
18351 if (themeToggle) {
18352 themeToggle.addEventListener('click', function () {
18353 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
18354 applyTheme(nextTheme);
18355 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
18356 });
18357 }
18358
18359 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
18360 button.addEventListener('click', function () {
18361 var value = button.getAttribute('data-copy-value') || '';
18362 if (!value) return;
18363 var originalText = button.textContent;
18364 function flashSuccess() {
18365 button.textContent = 'Copied!';
18366 setTimeout(function () { button.textContent = originalText; }, 1800);
18367 }
18368 function flashFail() {
18369 button.textContent = 'Copy failed';
18370 setTimeout(function () { button.textContent = originalText; }, 2000);
18371 }
18372 if (navigator.clipboard && navigator.clipboard.writeText) {
18373 navigator.clipboard.writeText(value).then(flashSuccess, function () {
18374 fallbackCopy(value, flashSuccess, flashFail);
18375 });
18376 } else {
18377 fallbackCopy(value, flashSuccess, flashFail);
18378 }
18379 });
18380 });
18381 function fallbackCopy(text, onSuccess, onFail) {
18382 try {
18383 var ta = document.createElement('textarea');
18384 ta.value = text;
18385 ta.style.position = 'fixed';
18386 ta.style.top = '-9999px';
18387 ta.style.left = '-9999px';
18388 document.body.appendChild(ta);
18389 ta.focus();
18390 ta.select();
18391 var ok = document.execCommand('copy');
18392 document.body.removeChild(ta);
18393 if (ok) { onSuccess(); } else { onFail(); }
18394 } catch (e) { onFail(); }
18395 }
18396
18397 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18398 btn.addEventListener('click', function () {
18399 var folder = btn.getAttribute('data-folder') || '';
18400 if (!folder) return;
18401 var orig = btn.textContent;
18402 fetch('/open-path?path=' + encodeURIComponent(folder))
18403 .then(function (r) { return r.json(); })
18404 .then(function (d) {
18405 if (d && d.server_mode_disabled) {
18406 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18407 } else if (d && d.ok) {
18408 btn.textContent = 'Opened!';
18409 setTimeout(function () { btn.textContent = orig; }, 1800);
18410 }
18411 })
18412 .catch(function () {
18413 btn.textContent = 'Failed';
18414 setTimeout(function () { btn.textContent = orig; }, 2000);
18415 });
18416 });
18417 });
18418
18419 loadSavedTheme();
18420
18421 // ── Compact number formatting for stat chips ──────────────────────────
18422 (function(){
18423 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();}
18424 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
18425 var raw=parseInt(chip.getAttribute('data-raw'),10);
18426 if(isNaN(raw))return;
18427 var valEl=chip.querySelector('.stat-chip-val');
18428 if(valEl)valEl.textContent=fmt(raw);
18429 var exactEl=chip.querySelector('.stat-chip-exact');
18430 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
18431 });
18432 // Code density chip
18433 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
18434 var code=parseInt(chip.getAttribute('data-code'),10);
18435 var phys=parseInt(chip.getAttribute('data-physical'),10);
18436 if(isNaN(code)||isNaN(phys)||phys===0)return;
18437 var pct=(code/phys*100).toFixed(1)+'%';
18438 var valEl=chip.querySelector('.stat-chip-val');
18439 if(valEl)valEl.textContent=pct;
18440 });
18441 // Populate author handle from data-author attribute
18442 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
18443 var author=chip.getAttribute('data-author');
18444 var el=chip.querySelector('.author-handle');
18445 if(el)el.textContent='/'+author.replace(/\s+/g,'');
18446 });
18447 // Click-to-copy on run-id-chip elements
18448 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
18449 chip.addEventListener('click',function(){
18450 var val=chip.getAttribute('data-copy');
18451 if(!val)return;
18452 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
18453 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);}
18454 chip.classList.add('chip-copied-flash');
18455 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
18456 });
18457 });
18458 })();
18459
18460 // ── Shared tooltip for all result-page charts ─────────────────────────
18461 var rTT=(function(){
18462 var el=document.getElementById('r-tt');
18463 if(!el)return{s:function(){},h:function(){},m:function(){}};
18464 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
18465 function hide(){el.style.display='none';}
18466 function move(e){
18467 var x=e.clientX+16,y=e.clientY-12;
18468 var r=el.getBoundingClientRect();
18469 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
18470 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
18471 el.style.left=x+'px';el.style.top=y+'px';
18472 }
18473 return{s:show,h:hide,m:move};
18474 })();
18475 window.rTT=rTT;
18476
18477 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
18478 (function(){
18479 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18480 document.addEventListener('mouseover',function(e){
18481 var t=e.target;
18482 while(t&&t.getAttribute){
18483 var l=t.getAttribute('data-ttl');
18484 if(l!==null){
18485 var v=t.getAttribute('data-ttv')||'';
18486 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
18487 return;
18488 }
18489 t=t.parentNode;
18490 }
18491 });
18492 document.addEventListener('mouseout',function(e){
18493 var t=e.target;
18494 while(t&&t.getAttribute){
18495 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
18496 t=t.parentNode;
18497 }
18498 });
18499 document.addEventListener('mousemove',function(e){
18500 var el=document.getElementById('r-tt');
18501 if(el&&el.style.display!=='none')rTT.m(e);
18502 });
18503 })();
18504
18505 // ── Language overview charts ───────────────────────────────────────────
18506 (function(){
18507 var D={{ lang_chart_json|safe }};
18508 if(!D||!D.length)return;
18509 var el=document.getElementById('result-lang-charts');
18510 if(!el)return;
18511 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18512 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
18513 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18514 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();}
18515 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18516 function px(n){return Math.round(n);}
18517 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+'"';}
18518 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
18519
18520 // Donut chart — height matches the stacked-bar chart so both panels align
18521 var rHb_d=28;
18522 var DH=Math.max(220,D.length*rHb_d+32);
18523 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
18524 var legX=204,DW=360;
18525 var legCount=D.length;
18526 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
18527 var legYStart=Math.round((DH-legCount*legSpacing)/2);
18528 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">';
18529 if(D.length===1){
18530 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
18531 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+'"/>';
18532 } else {
18533 var ang=-Math.PI/2;
18534 D.forEach(function(d,i){
18535 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18536 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
18537 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
18538 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
18539 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
18540 var pct=Math.round(d.code/tot*100);
18541 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"/>';
18542 ang+=sw;
18543 });
18544 }
18545 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
18546 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
18547 D.forEach(function(d,i){
18548 var ly=legYStart+i*legSpacing;
18549 var pctL=Math.round(d.code/tot*100);
18550 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
18551 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
18552 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
18553 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
18554 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
18555 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
18556 ds+='</g>';
18557 });
18558 ds+='</svg>';
18559
18560 // Horizontal stacked-bar chart — fills container width
18561 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
18562 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
18563 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">';
18564 D.forEach(function(d,i){
18565 var y=6+i*rHb,x=LW;
18566 var phys=d.physical||d.code+d.comments+d.blanks;
18567 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
18568 bs+='<g class="lang-bar-row">';
18569 bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
18570 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>';
18571 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;
18572 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;
18573 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"/>';
18574 bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
18575 bs+='</g>';
18576 });
18577 var ly=SH-14;
18578 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
18579 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
18580 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
18581 var totAll=totC+totCm+totBl||1;
18582 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
18583 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
18584 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
18585 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
18586 bs+='<g data-kind="code" style="cursor:pointer;">'
18587 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
18588 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
18589 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
18590 +'</g>';
18591 bs+='<g data-kind="comment" style="cursor:pointer;">'
18592 +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
18593 +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
18594 +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
18595 +'</g>';
18596 bs+='<g data-kind="blank" style="cursor:pointer;">'
18597 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
18598 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
18599 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
18600 +'</g>';
18601 bs+='</svg>';
18602 el.innerHTML='<div class="r-lang-overview">'+
18603 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
18604 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
18605 '</div>';
18606 function wireDonutLegend(svg){
18607 if(!svg)return;
18608 var paths=svg.querySelectorAll('path[data-lang]');
18609 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';}}}
18610 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
18611 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;}});
18612 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
18613 }
18614 function wireMixLegend(svg){
18615 if(!svg)return;
18616 var legGs=svg.querySelectorAll('g[data-kind]');
18617 var allRects=svg.querySelectorAll('rect[data-kind]');
18618 if(!legGs.length)return;
18619 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';}}
18620 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='';}}
18621 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]);}
18622 }
18623 wireDonutLegend(el.querySelector('svg'));
18624 wireMixLegend(el.querySelectorAll('svg')[1]);
18625
18626 // ── Language breakdown Full View expand ─────────────────────────────────
18627 var langOvBtn=document.getElementById('result-lang-overview-expand');
18628 if(langOvBtn){langOvBtn.addEventListener('click',function(){
18629 var src=document.getElementById('result-lang-charts');
18630 if(!src)return;
18631 var overlay=document.createElement('div');
18632 overlay.className='r-chart-modal-overlay';
18633 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>';
18634 document.body.appendChild(overlay);
18635 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18636 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18637 var wrap=document.getElementById('result-lang-overview-modal-wrap');
18638 if(wrap){
18639 wrap.innerHTML=src.innerHTML;
18640 var svgs=wrap.querySelectorAll('svg');
18641 for(var i=0;i<svgs.length;i++){
18642 svgs[i].removeAttribute('width');
18643 svgs[i].removeAttribute('height');
18644 svgs[i].style.cssText='display:block;width:100%;height:auto;';
18645 }
18646 var ov=wrap.querySelector('.r-lang-overview');
18647 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
18648 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
18649 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
18650 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
18651 wireDonutLegend(wrap.querySelector('svg'));
18652 wireMixLegend(wrap.querySelectorAll('svg')[1]);
18653 requestAnimationFrame(function(){
18654 var ss=wrap.querySelectorAll('svg');
18655 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%;';}}
18656 });
18657 }
18658 });}
18659 })();
18660
18661 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
18662 (function(){
18663 var LANG_D={{ lang_chart_json|safe }};
18664 var SCAT_D={{ scatter_chart_json|safe }};
18665 var SEM_D={{ semantic_chart_json|safe }};
18666 var SUB_D={{ submodule_chart_json|safe }};
18667 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
18668 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18669 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();}
18670 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18671 function px(n){return Math.round(n);}
18672 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+'"';}
18673
18674 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
18675 function renderCompositionInEl(el,mode,shOvr){
18676 if(!el||!LANG_D||!LANG_D.length)return;
18677 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18678 var LW=110,SH=shOvr||224;
18679 var svgW=Math.max(320,el.offsetWidth||480);
18680 var BW=Math.max(120,svgW-LW-80);
18681 var legendH=24,topPad=4;
18682 var n=LANG_D.length||1;
18683 var rowTotal=Math.floor((SH-legendH-topPad)/n);
18684 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18685 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">';
18686 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
18687 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
18688 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
18689 var totAll2=totC2+totCm2+totBl2||1;
18690 if(mode==='pct'){
18691 LANG_D.forEach(function(d,i){
18692 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
18693 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
18694 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18695 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>';
18696 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;
18697 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;
18698 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+'"/>';
18699 var pct=Math.round((d.code||0)/tot2*100);
18700 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>';
18701 });
18702 } else {
18703 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
18704 LANG_D.forEach(function(d,i){
18705 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
18706 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18707 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>';
18708 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;
18709 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;
18710 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+'"/>';
18711 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>';
18712 });
18713 }
18714 var ly=SH-legendH+4;
18715 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
18716 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
18717 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
18718 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
18719 s+='<g data-kind="code" style="cursor:pointer;">'
18720 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
18721 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
18722 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
18723 +'</g>';
18724 s+='<g data-kind="comment" style="cursor:pointer;">'
18725 +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
18726 +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
18727 +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
18728 +'</g>';
18729 s+='<g data-kind="blank" style="cursor:pointer;">'
18730 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
18731 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
18732 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
18733 +'</g>';
18734 s+='</svg>';
18735 el.innerHTML=s;
18736 wireMixLegendEl(el);
18737 }
18738 function wireMixLegendEl(container){
18739 var svg=container&&container.querySelector('svg');
18740 if(!svg)return;
18741 var legGs=svg.querySelectorAll('g[data-kind]');
18742 var allRects=svg.querySelectorAll('rect[data-kind]');
18743 if(!legGs.length)return;
18744 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';}}
18745 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='';}}
18746 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]);}
18747 }
18748 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
18749 renderComposition('abs');
18750 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
18751 btn.addEventListener('click',function(){
18752 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
18753 btn.classList.add('active');
18754 renderComposition(btn.getAttribute('data-rcomp'));
18755 });
18756 });
18757
18758 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
18759 function renderScatterInEl(el,hOvr){
18760 if(!el||!SCAT_D||!SCAT_D.length)return;
18761 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
18762 var W=Math.max(320,el.offsetWidth||480);
18763 var cW=W-PL-PR,cH=H-PT-PB;
18764 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
18765 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
18766 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
18767 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">';
18768 [0,0.25,0.5,0.75,1].forEach(function(t){
18769 var y=PT+cH*(1-t);
18770 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
18771 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>';
18772 });
18773 [0,0.25,0.5,0.75,1].forEach(function(t){
18774 var x=PL+cW*t;
18775 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
18776 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>';
18777 });
18778 SCAT_D.forEach(function(d,i){
18779 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
18780 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
18781 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"/>';
18782 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>';
18783 });
18784 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>';
18785 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>';
18786 s+='</svg>';
18787 el.innerHTML=s;
18788 }
18789 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
18790
18791 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
18792 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
18793 // the old vertical column layout on wide containers.
18794 function renderSemanticInEl(el,key,sh){
18795 if(!el||!SEM_D||!SEM_D.length)return;
18796 var n2=SEM_D.length||1;
18797 var LW=112,SH=sh||Math.max(180,n2*28+26);
18798 var svgW=Math.max(320,el.offsetWidth||480);
18799 var BW=Math.max(120,svgW-LW-80);
18800 var topPad=4,botPad=14;
18801 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
18802 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
18803 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
18804 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">';
18805 SEM_D.forEach(function(d,i){
18806 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
18807 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>';
18808 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"/>';
18809 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>';
18810 });
18811 s+='</svg>';
18812 el.innerHTML=s;
18813 }
18814 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
18815 var semSel=document.getElementById('r-semantic-metric');
18816 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
18817 var semExpand=document.getElementById('r-semantic-expand');
18818 if(semExpand){
18819 semExpand.addEventListener('click',function(){
18820 var key=semSel?semSel.value:'functions';
18821 var n=SEM_D.length||1;
18822 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
18823 var modalH=Math.min(Math.max(360,n*38+60),maxH);
18824 var overlay=document.createElement('div');
18825 overlay.className='r-chart-modal-overlay';
18826 var optHtml=
18827 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
18828 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
18829 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
18830 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
18831 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
18832 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>';
18833 document.body.appendChild(overlay);
18834 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18835 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18836 var modalEl=document.getElementById('r-sem-modal-chart');
18837 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
18838 var modalSel=document.getElementById('r-sem-modal-metric');
18839 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
18840 });
18841 }
18842
18843 // ── Expand buttons: re-render charts at large size inside modal ──────────
18844 (function(){
18845 function makeExpandModal(title,mH,subtitle,ctrlHtml){
18846 var overlay=document.createElement('div');
18847 overlay.className='r-chart-modal-overlay';
18848 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
18849 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
18850 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>';
18851 document.body.appendChild(overlay);
18852 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18853 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18854 return overlay.querySelector('.r-expand-modal-chart');
18855 }
18856 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
18857 var compExpandBtn=document.getElementById('r-composition-expand');
18858 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
18859 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
18860 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
18861 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
18862 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
18863 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
18864 if(wrap){
18865 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
18866 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
18867 btn.addEventListener('click',function(){
18868 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
18869 btn.classList.add('active');
18870 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
18871 });
18872 });
18873 }
18874 });}
18875 var scatExpandBtn=document.getElementById('r-scatter-expand');
18876 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
18877 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
18878 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
18879 });}
18880 var densExpandBtn=document.getElementById('r-density-expand');
18881 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
18882 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
18883 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
18884 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
18885 });}
18886 var avgExpandBtn=document.getElementById('r-avglines-expand');
18887 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
18888 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
18889 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
18890 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
18891 });}
18892 var subExpandBtn=document.getElementById('r-submodule-expand');
18893 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
18894 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
18895 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
18896 var metCtrl=
18897 '<select class="r-chart-select" id="r-sub-modal-metric">'
18898 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
18899 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
18900 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
18901 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
18902 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
18903 +'</select>';
18904 var sortCtrl=
18905 '<select class="r-chart-select" id="r-sub-modal-sort">'
18906 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
18907 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
18908 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
18909 +'</select>';
18910 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
18911 if(wrap){
18912 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
18913 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
18914 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
18915 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
18916 if(mSub)mSub.addEventListener('change',reRenderSub);
18917 if(mSort)mSort.addEventListener('change',reRenderSub);
18918 }
18919 });}
18920 })();
18921
18922 // ── Comment Density: comments / (code + comments) per language ───────────
18923 function renderDensityInEl(el,shOvr){
18924 if(!el||!LANG_D||!LANG_D.length)return;
18925 var n=LANG_D.length||1;
18926 var LW=112,SH=shOvr||Math.max(180,n*28+26);
18927 var svgW=Math.max(320,el.offsetWidth||480);
18928 var BW=Math.max(120,svgW-LW-80);
18929 var topPad=4,botPad=26;
18930 var rowTotal=Math.floor((SH-topPad-botPad)/n);
18931 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18932 var densities=LANG_D.map(function(d){
18933 var sig=(d.code||0)+(d.comments||0);
18934 return sig>0?(d.comments||0)/sig:0;
18935 });
18936 var maxDen=Math.max.apply(null,densities)||1;
18937 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">';
18938 LANG_D.forEach(function(d,i){
18939 var den=densities[i],bw=den/maxDen*BW;
18940 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
18941 var pct=Math.round(den*100);
18942 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>';
18943 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"/>';
18944 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
18945 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>';
18946 });
18947 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>';
18948 s+='</svg>';
18949 el.innerHTML=s;
18950 }
18951 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
18952 renderDensity();
18953
18954 // ── Avg Lines per File: code / files per language ─────────────────────
18955 function renderAvgLinesInEl(el,shOvr){
18956 if(!el||!LANG_D||!LANG_D.length)return;
18957 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
18958 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
18959 var n=data.length||1;
18960 var LW=112,SH=shOvr||Math.max(180,n*28+26);
18961 var svgW=Math.max(320,el.offsetWidth||480);
18962 var BW=Math.max(120,svgW-LW-80);
18963 var topPad=4,botPad=26;
18964 var rowTotal=Math.floor((SH-topPad-botPad)/n);
18965 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18966 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
18967 var maxAvg=Math.max.apply(null,avgs)||1;
18968 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">';
18969 data.forEach(function(d,i){
18970 var avg=avgs[i],bw=avg/maxAvg*BW;
18971 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
18972 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>';
18973 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"/>';
18974 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
18975 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>';
18976 });
18977 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>';
18978 s+='</svg>';
18979 el.innerHTML=s;
18980 }
18981 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
18982 renderAvgLines();
18983
18984 // ── Repository Overview: overall row + per-submodule rows ────────────
18985 function renderSubmoduleInEl(el,key,sort,shOvr){
18986 if(!el)return;
18987 var overall={
18988 name:'Overall',
18989 code:{{ code_lines }},
18990 comment:{{ comment_lines }},
18991 blank:{{ blank_lines }},
18992 physical:{{ physical_lines }},
18993 files:{{ files_analyzed }},
18994 isOverall:true
18995 };
18996 var subs=SUB_D.slice();
18997 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
18998 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
18999 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
19000 var data=[overall].concat(subs);
19001 var rowH=32,bH=22,sepH=subs.length>0?14:0;
19002 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
19003 var svgW=Math.max(320,el.offsetWidth||480);
19004 var LW=116,BW=Math.max(200,svgW-LW-54);
19005 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
19006 var OVERALL_COL='#6b7280';
19007 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">';
19008 var yOff=4;
19009 data.forEach(function(d,i){
19010 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
19011 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
19012 var label=d.name||d.path||'?';
19013 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>';
19014 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
19015 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19016 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>';
19017 yOff+=rowH;
19018 if(d.isOverall&&subs.length>0){
19019 yOff+=sepH;
19020 }
19021 });
19022 s+='</svg>';
19023 el.innerHTML=s;
19024 }
19025 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
19026 var subSel=document.getElementById('r-sub-metric');
19027 var sortSel=document.getElementById('r-sub-sort');
19028 renderSubmodule('code','desc');
19029 if(subSel){
19030 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
19031 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
19032 }
19033
19034 // Equalise heights within each chart row: if one chart in a grid row is taller
19035 // than its neighbour, re-render the shorter one at the taller height so bars fill
19036 // the available vertical space instead of leaving a gap.
19037 function syncRowHeights(){
19038 var avgEl=document.getElementById('r-avglines-chart');
19039 var subEl=document.getElementById('r-submodule-chart');
19040 if(avgEl&&subEl){
19041 var avgSvg=avgEl.querySelector('svg');
19042 var subSvg=subEl.querySelector('svg');
19043 if(avgSvg&&subSvg){
19044 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
19045 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
19046 var key=subSel?subSel.value||'code':'code';
19047 var sort=sortSel?sortSel.value:'desc';
19048 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
19049 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
19050 }
19051 }
19052 var semEl=document.getElementById('r-semantic-chart');
19053 var denEl=document.getElementById('r-density-chart');
19054 if(semEl&&denEl){
19055 var semSvg=semEl.querySelector('svg');
19056 var denSvg=denEl.querySelector('svg');
19057 if(semSvg&&denSvg){
19058 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
19059 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
19060 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
19061 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
19062 }
19063 }
19064 }
19065 syncRowHeights();
19066
19067 // Re-render all SVG charts when the window is resized so bars fill the card.
19068 var _rResizeTimer;
19069 window.addEventListener('resize',function(){
19070 clearTimeout(_rResizeTimer);
19071 _rResizeTimer=setTimeout(function(){
19072 var rcompBtn=document.querySelector('[data-rcomp].active');
19073 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
19074 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
19075 if(semSel)renderSemantic(semSel.value||'functions');
19076 renderDensity();
19077 renderAvgLines();
19078 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
19079 syncRowHeights();
19080 },120);
19081 });
19082 })();
19083
19084 (function randomizeWatermarks() {
19085 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
19086 if (!wms.length) return;
19087 var placed = [];
19088 function tooClose(top, left) {
19089 for (var i = 0; i < placed.length; i++) {
19090 var dt = Math.abs(placed[i][0] - top);
19091 var dl = Math.abs(placed[i][1] - left);
19092 if (dt < 20 && dl < 18) return true;
19093 }
19094 return false;
19095 }
19096 function pick(leftBand) {
19097 for (var attempt = 0; attempt < 50; attempt++) {
19098 var top = Math.random() * 85 + 5;
19099 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19100 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19101 }
19102 var top = Math.random() * 85 + 5;
19103 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19104 placed.push([top, left]);
19105 return [top, left];
19106 }
19107 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
19108 var half = Math.floor(wms.length / 2);
19109 wms.forEach(function (img, i) {
19110 var pos = pick(i < half);
19111 var size = Math.floor(Math.random() * 100 + 160);
19112 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
19113 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
19114 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;
19115 });
19116 })();
19117
19118 (function spawnCodeParticles() {
19119 var container = document.getElementById('code-particles');
19120 if (!container) return;
19121 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'];
19122 for (var i = 0; i < 38; i++) {
19123 (function(idx) {
19124 var el = document.createElement('span');
19125 el.className = 'code-particle';
19126 el.textContent = snippets[idx % snippets.length];
19127 var left = Math.random() * 94 + 2;
19128 var top = Math.random() * 88 + 6;
19129 var dur = (Math.random() * 10 + 9).toFixed(1);
19130 var delay = (Math.random() * 18).toFixed(1);
19131 var rot = (Math.random() * 26 - 13).toFixed(1);
19132 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19133 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';
19134 container.appendChild(el);
19135 })(i);
19136 }
19137 })();
19138
19139 {% if pdf_generating %}
19140 // Poll for PDF readiness and swap the disabled button to a live link once done.
19141 (function() {
19142 var openBtn = document.getElementById('pdf-open-btn');
19143 var dlBtn = document.getElementById('pdf-download-btn');
19144 function checkPdf() {
19145 fetch('/api/runs/{{ run_id }}/pdf-status')
19146 .then(function(r) { return r.json(); })
19147 .then(function(d) {
19148 if (d.ready) {
19149 if (openBtn) {
19150 var a = document.createElement('a');
19151 a.className = 'button';
19152 a.id = 'pdf-open-btn';
19153 a.href = '/runs/pdf/{{ run_id }}';
19154 a.target = '_blank';
19155 a.rel = 'noopener';
19156 a.textContent = 'Open PDF';
19157 openBtn.replaceWith(a);
19158 }
19159 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
19160 } else {
19161 setTimeout(checkPdf, 3000);
19162 }
19163 })
19164 .catch(function() { setTimeout(checkPdf, 5000); });
19165 }
19166 setTimeout(checkPdf, 3000);
19167 })();
19168 {% endif %}
19169
19170 })();
19171 </script>
19172 <script nonce="{{ csp_nonce }}">
19173 (function(){
19174 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'}];
19175 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);});}
19176 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19177 function init(){
19178 var btn=document.getElementById('settings-btn');if(!btn)return;
19179 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19180 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>';
19181 document.body.appendChild(m);
19182 var g=document.getElementById('scheme-grid');
19183 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);});
19184 var cl=document.getElementById('settings-close');
19185 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);
19186 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');});
19187 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19188 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19189 }
19190 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19191 }());
19192 </script>
19193 <footer class="site-footer">
19194 local code analysis - metrics, history and reports
19195 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19196 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19197 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19198 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19199 · <a href="/api-docs" rel="noopener">REST API</a>
19200 </footer>
19201 {% if confluence_configured %}
19202 <script nonce="{{ csp_nonce }}">
19203 (function() {
19204 var postBtn = document.getElementById('postConfluenceBtn');
19205 var copyBtn = document.getElementById('copyWikiBtn');
19206 var modal = document.getElementById('confluenceModal');
19207 if (!postBtn || !modal) return;
19208
19209 postBtn.addEventListener('click', function() {
19210 document.getElementById('confStatus').style.display = 'none';
19211 modal.style.display = 'flex';
19212 });
19213 document.getElementById('confCancelBtn').addEventListener('click', function() {
19214 modal.style.display = 'none';
19215 });
19216 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19217
19218 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
19219 var btn = this;
19220 btn.disabled = true;
19221 var status = document.getElementById('confStatus');
19222 status.style.display = 'block';
19223 status.style.background = '#dbeafe';
19224 status.style.color = '#1e40af';
19225 status.textContent = 'Posting to Confluence…';
19226 var resp = await fetch('/api/confluence/post', {
19227 method: 'POST',
19228 headers: { 'Content-Type': 'application/json' },
19229 body: JSON.stringify({
19230 run_id: '{{ run_id }}',
19231 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
19232 report_url: document.getElementById('confReportUrl').value.trim() || null
19233 })
19234 });
19235 var data = await resp.json();
19236 if (data.ok) {
19237 status.style.background = '#dcfce7'; status.style.color = '#166534';
19238 status.textContent = 'Posted! Page ID: ' + data.page_id;
19239 } else {
19240 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19241 status.textContent = 'Error: ' + (data.error || 'Unknown error');
19242 }
19243 btn.disabled = false;
19244 });
19245
19246 if (copyBtn) {
19247 copyBtn.addEventListener('click', async function() {
19248 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
19249 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
19250 var text = await resp.text();
19251 try {
19252 await navigator.clipboard.writeText(text);
19253 var orig = copyBtn.textContent;
19254 copyBtn.textContent = 'Copied!';
19255 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
19256 } catch(e) {
19257 alert('Clipboard write failed — check browser permissions.');
19258 }
19259 });
19260 }
19261 })();
19262 </script>
19263 {% endif %}
19264 <script nonce="{{ csp_nonce }}">
19265 (function() {
19266 var deleteBtn = document.getElementById('delete-run-btn');
19267 var modal = document.getElementById('delete-run-modal');
19268 var cancelBtn = document.getElementById('delete-run-cancel');
19269 var confirmBtn= document.getElementById('delete-run-confirm');
19270 if (!deleteBtn || !modal) return;
19271 deleteBtn.addEventListener('click', function() {
19272 document.getElementById('delete-run-status').style.display = 'none';
19273 modal.style.display = 'flex';
19274 });
19275 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
19276 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19277 confirmBtn.addEventListener('click', async function() {
19278 confirmBtn.disabled = true;
19279 cancelBtn.disabled = true;
19280 var status = document.getElementById('delete-run-status');
19281 status.style.display = 'block';
19282 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
19283 status.textContent = 'Deleting…';
19284 try {
19285 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
19286 if (resp.status === 204 || resp.ok) {
19287 status.style.background = '#dcfce7'; status.style.color = '#166534';
19288 status.textContent = 'Deleted. Redirecting…';
19289 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
19290 } else {
19291 var d = await resp.json().catch(function(){return {};});
19292 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19293 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
19294 confirmBtn.disabled = false;
19295 cancelBtn.disabled = false;
19296 }
19297 } catch (e) {
19298 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19299 status.textContent = 'Network error: ' + String(e);
19300 confirmBtn.disabled = false;
19301 cancelBtn.disabled = false;
19302 }
19303 });
19304 })();
19305 </script>
19306 <script nonce="{{ csp_nonce }}">(function(){
19307 var bundleBtn = document.getElementById('download-bundle-btn');
19308 if (bundleBtn) {
19309 bundleBtn.addEventListener('click', function() {
19310 bundleBtn.disabled = true;
19311 var orig = bundleBtn.textContent;
19312 bundleBtn.textContent = 'Preparing…';
19313 fetch('/api/runs/{{ run_id }}/bundle')
19314 .then(function(r) {
19315 if (!r.ok) throw new Error('HTTP ' + r.status);
19316 return r.blob();
19317 })
19318 .then(function(blob) {
19319 var url = URL.createObjectURL(blob);
19320 var a = document.createElement('a');
19321 a.href = url;
19322 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
19323 document.body.appendChild(a);
19324 a.click();
19325 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
19326 bundleBtn.disabled = false;
19327 bundleBtn.textContent = orig;
19328 })
19329 .catch(function(e) {
19330 bundleBtn.disabled = false;
19331 bundleBtn.textContent = orig;
19332 alert('Bundle download failed: ' + String(e));
19333 });
19334 });
19335 }
19336 })();</script>
19337 <script nonce="{{ csp_nonce }}">(function(){
19338 var dot=document.getElementById('status-dot');
19339 var pingEl=document.getElementById('server-ping-ms');
19340 var tipEl=document.getElementById('server-tip-ping');
19341 var fm=document.getElementById('footer-mode');
19342 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)';}}
19343 function doPing(){
19344 var t0=performance.now();
19345 fetch('/healthz',{cache:'no-store'})
19346 .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);})
19347 .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)';}});
19348 }
19349 doPing();
19350 setInterval(doPing,5000);
19351 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');}
19352 })();</script>
19353 {% if let Some(banner) = report_header_footer %}
19354 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
19355 {% endif %}
19356</body>
19357</html>
19358"##,
19359 ext = "html"
19360)]
19361#[allow(clippy::struct_excessive_bools)]
19363struct ResultTemplate {
19364 version: &'static str,
19365 report_title: String,
19366 project_path: String,
19367 output_dir: String,
19368 run_id: String,
19369 files_analyzed: u64,
19370 files_skipped: u64,
19371 physical_lines: u64,
19372 code_lines: u64,
19373 comment_lines: u64,
19374 blank_lines: u64,
19375 mixed_lines: u64,
19376 functions: u64,
19377 classes: u64,
19378 variables: u64,
19379 imports: u64,
19380 html_url: Option<String>,
19381 pdf_url: Option<String>,
19382 json_url: Option<String>,
19383 html_download_url: Option<String>,
19384 pdf_download_url: Option<String>,
19385 json_download_url: Option<String>,
19386 html_path: Option<String>,
19387 json_path: Option<String>,
19388 prev_run_id: Option<String>,
19389 prev_run_timestamp: Option<String>,
19390 prev_run_code_lines: Option<u64>,
19391 prev_fa_str: String,
19393 prev_fs_str: String,
19394 prev_pl_str: String,
19395 prev_cl_str: String,
19396 prev_cml_str: String,
19397 prev_bl_str: String,
19398 delta_fa_str: String,
19400 delta_fa_class: String,
19401 delta_fs_str: String,
19402 delta_fs_class: String,
19403 delta_pl_str: String,
19404 delta_pl_class: String,
19405 delta_cl_str: String,
19406 delta_cl_class: String,
19407 delta_cml_str: String,
19408 delta_cml_class: String,
19409 delta_bl_str: String,
19410 delta_bl_class: String,
19411 delta_lines_added: Option<i64>,
19413 delta_lines_removed: Option<i64>,
19414 delta_lines_net_str: String,
19415 delta_lines_net_class: String,
19416 delta_files_added: Option<usize>,
19417 delta_files_removed: Option<usize>,
19418 delta_files_modified: Option<usize>,
19419 delta_files_unchanged: Option<usize>,
19420 delta_unmodified_lines: Option<u64>,
19421 git_branch: Option<String>,
19423 git_commit: Option<String>,
19424 git_commit_long: Option<String>,
19425 git_author: Option<String>,
19426 git_commit_url: Option<String>,
19427 scan_performed_by: String,
19429 scan_time_display: String,
19430 os_display: String,
19431 test_count: u64,
19432 prev_scan_count: usize,
19434 current_scan_number: usize,
19435 submodule_rows: Vec<SubmoduleRow>,
19437 scan_config_url: String,
19438 lang_chart_json: String,
19439 #[allow(dead_code)]
19441 scatter_chart_json: String,
19442 #[allow(dead_code)]
19443 semantic_chart_json: String,
19444 #[allow(dead_code)]
19445 submodule_chart_json: String,
19446 #[allow(dead_code)]
19447 has_submodule_data: bool,
19448 #[allow(dead_code)]
19449 has_semantic_data: bool,
19450 pdf_generating: bool,
19451 csp_nonce: String,
19452 confluence_configured: bool,
19454 server_mode: bool,
19455 report_header_footer: Option<String>,
19457 run_id_short: String,
19458 #[allow(dead_code)]
19460 is_offline: bool,
19461}
19462
19463#[derive(Template)]
19464#[template(
19465 source = r##"
19466<!doctype html>
19467<html lang="en">
19468<head>
19469 <meta charset="utf-8">
19470 <meta name="viewport" content="width=device-width, initial-scale=1">
19471 <title>OxideSLOC | Analyzing…</title>
19472 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19473 <style nonce="{{ csp_nonce }}">
19474 :root {
19475 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19476 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19477 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
19478 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19479 }
19480 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19481 *{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;}
19482 .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);}
19483 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19484 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
19485 .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));}
19486 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19487 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19488 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19489 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19490 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19491 @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; } }
19492 .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;}
19493 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19494 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19495 .page-body{padding:32px 24px 36px;}
19496 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
19497 .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;}
19498 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
19499 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
19500 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
19501 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
19502 .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;}
19503 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
19504 .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;}
19505 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
19506 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
19507 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
19508 .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;}
19509 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
19510 .hidden{display:none!important;}
19511 .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;}
19512 .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;}
19513 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
19514 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
19515 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
19516 .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);}
19517 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
19518 .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;}
19519 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
19520 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19521 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19522 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
19523 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19524 .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;}
19525 @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));}}
19526 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19527 .site-footer a{color:var(--muted);}
19528 .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;}
19529 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
19530 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
19531 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
19532 </style>
19533</head>
19534<body>
19535 <div class="background-watermarks" aria-hidden="true">
19536 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19537 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19538 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19539 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19540 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19541 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19542 </div>
19543 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19544 <nav class="top-nav">
19545 <div class="top-nav-inner">
19546 <a href="/" class="brand">
19547 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
19548 <div class="brand-copy">
19549 <h1 class="brand-title">OxideSLOC</h1>
19550 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
19551 </div>
19552 </a>
19553 <div class="nav-right">
19554 <a class="nav-pill" href="/">Home</a>
19555 <div class="nav-dropdown">
19556 <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>
19557 <div class="nav-dropdown-menu">
19558 <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>
19559 </div>
19560 </div>
19561 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19562 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19563 <div class="nav-dropdown">
19564 <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>
19565 <div class="nav-dropdown-menu">
19566 <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>
19567 </div>
19568 </div>
19569 <div class="server-status-wrap" id="server-status-wrap">
19570 <div class="nav-pill server-online-pill" id="server-status-pill">
19571 <span class="status-dot" id="status-dot"></span>
19572 <span id="server-status-label">Server</span>
19573 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19574 </div>
19575 <div class="server-status-tip">
19576 OxideSLOC is running — accessible on your network.
19577 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19578 </div>
19579 </div>
19580 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19581 <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>
19582 </button>
19583 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19584 <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>
19585 <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>
19586 </button>
19587 </div>
19588 </div>
19589 </nav>
19590 <div class="page-body">
19591 <div class="wait-panel">
19592 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
19593 <h2 class="wait-title">Analyzing your project…</h2>
19594 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
19595 <div class="path-block">{{ project_path }}</div>
19596 <div class="metrics-row">
19597 <div class="metric-card">
19598 <div class="metric-label">Elapsed</div>
19599 <div class="metric-value" id="elapsed">0s</div>
19600 </div>
19601 <div class="metric-card">
19602 <div class="metric-label">Phase</div>
19603 <div class="metric-value" id="phase">Starting</div>
19604 </div>
19605 <div class="metric-card hidden" id="files-card">
19606 <div class="metric-label">Files</div>
19607 <div class="metric-value" id="files-progress">0</div>
19608 </div>
19609 </div>
19610 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
19611 <div class="warn-slow hidden" id="warn-slow">
19612 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.
19613 </div>
19614 <div class="err-panel hidden" id="err-panel">
19615 <strong>Analysis failed</strong>
19616 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
19617 </div>
19618 <div class="actions hidden" id="actions">
19619 <a href="/scan" class="btn-primary">Try Again</a>
19620 <a href="/view-reports" class="btn-outline">View Reports</a>
19621 </div>
19622 </div>
19623 </div>
19624 <script nonce="{{ csp_nonce }}">
19625 (function() {
19626 var WAIT_ID = {{ wait_id_json|safe }};
19627 var startTime = Date.now();
19628 var pollInterval = 1500;
19629 var retries = 0;
19630 var maxRetries = 5;
19631 var warnShown = false;
19632
19633 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();}
19634
19635 function elapsed() {
19636 return Math.floor((Date.now() - startTime) / 1000);
19637 }
19638
19639 function updateElapsed() {
19640 var s = elapsed();
19641 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
19642 }
19643
19644 function setPhase(txt) {
19645 document.getElementById('phase').textContent = txt;
19646 }
19647
19648 var elapsedTimer = setInterval(updateElapsed, 1000);
19649
19650 function poll() {
19651 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
19652 .then(function(r) {
19653 if (!r.ok) throw new Error('HTTP ' + r.status);
19654 return r.json();
19655 })
19656 .then(function(data) {
19657 retries = 0;
19658 if (data.state === 'complete') {
19659 clearInterval(elapsedTimer);
19660 setPhase('Done');
19661 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
19662 } else if (data.state === 'failed') {
19663 clearInterval(elapsedTimer);
19664 setPhase('Failed');
19665 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
19666 document.getElementById('err-panel').classList.remove('hidden');
19667 document.getElementById('actions').classList.remove('hidden');
19668 } else {
19669 // still running
19670 var s = elapsed();
19671 if (s > 90 && !warnShown) {
19672 warnShown = true;
19673 document.getElementById('warn-slow').classList.remove('hidden');
19674 }
19675 setPhase(data.phase || 'Running');
19676 var fd = data.files_done || 0, ft = data.files_total || 0;
19677 if (ft > 0) {
19678 var card = document.getElementById('files-card');
19679 if (card) card.classList.remove('hidden');
19680 var fp = document.getElementById('files-progress');
19681 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
19682 }
19683 setTimeout(poll, pollInterval);
19684 }
19685 })
19686 .catch(function(err) {
19687 retries++;
19688 if (retries >= maxRetries) {
19689 clearInterval(elapsedTimer);
19690 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
19691 document.getElementById('err-panel').classList.remove('hidden');
19692 document.getElementById('actions').classList.remove('hidden');
19693 } else {
19694 // exponential back-off capped at 8s
19695 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
19696 }
19697 });
19698 }
19699
19700 setTimeout(poll, pollInterval);
19701 })();
19702 </script>
19703 <footer class="site-footer">
19704 local code analysis - metrics, history and reports
19705 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19706 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19707 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19708 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19709 · <a href="/api-docs" rel="noopener">REST API</a>
19710 </footer>
19711 <script nonce="{{ csp_nonce }}">
19712 (function(){
19713 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
19714 if(s==="dark")b.classList.add("dark-theme");
19715 var tt=document.getElementById("theme-toggle");
19716 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
19717 })();
19718 (function spawnCodeParticles(){
19719 var c=document.getElementById('code-particles');if(!c)return;
19720 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'];
19721 for(var i=0;i<32;i++){(function(idx){
19722 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
19723 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
19724 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
19725 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
19726 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
19727 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
19728 c.appendChild(el);
19729 })(i);}
19730 })();
19731 (function randomizeWatermarks(){
19732 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19733 var placed=[];
19734 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;}
19735 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];}
19736 var half=Math.floor(wms.length/2);
19737 wms.forEach(function(img,i){
19738 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
19739 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
19740 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
19741 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
19742 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
19743 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
19744 });
19745 })();
19746 </script>
19747 <script nonce="{{ csp_nonce }}">
19748 (function(){
19749 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'}];
19750 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);});}
19751 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19752 function init(){
19753 var btn=document.getElementById('settings-btn');if(!btn)return;
19754 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19755 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>';
19756 document.body.appendChild(m);
19757 var g=document.getElementById('scheme-grid');
19758 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);});
19759 var cl=document.getElementById('settings-close');
19760 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);
19761 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');});
19762 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19763 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19764 }
19765 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19766 }());
19767 </script>
19768 <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>
19769</body>
19770</html>
19771"##,
19772 ext = "html"
19773)]
19774struct ScanWaitTemplate {
19775 version: &'static str,
19776 wait_id_json: String,
19777 project_path: String,
19778 csp_nonce: String,
19779}
19780
19781#[derive(Template)]
19782#[template(
19783 source = r##"
19784<!doctype html>
19785<html lang="en">
19786<head>
19787 <meta charset="utf-8">
19788 <meta name="viewport" content="width=device-width, initial-scale=1">
19789 <title>OxideSLOC | Error</title>
19790 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19791 <style nonce="{{ csp_nonce }}">
19792 :root {
19793 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19794 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19795 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
19796 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19797 }
19798 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19799 *{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;}
19800 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19801 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19802 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
19803 .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);}
19804 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19805 .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));}
19806 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19807 .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;}
19808 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19809 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19810 @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; } }
19811 .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;}
19812 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19813 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19814 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19815 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19816 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19817 .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;}
19818 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19819 .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);}
19820 .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;}
19821 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19822 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19823 .settings-modal-body{padding:14px 16px 16px;}
19824 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19825 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19826 .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;}
19827 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19828 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19829 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19830 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19831 .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;}
19832 .tz-select:focus{border-color:var(--oxide);}
19833 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
19834 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
19835 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
19836 .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;}
19837 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
19838 .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);}
19839 .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;}
19840 .btn-secondary:hover{background:var(--line);}
19841 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
19842 .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;}
19843 .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;}
19844 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
19845 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
19846 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
19847 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
19848 .bug-report-panel.open{display:flex;}
19849 .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;}
19850 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
19851 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
19852 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
19853 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
19854 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
19855 .br-network-badge.online .br-net-dot{background:#2a6846;}
19856 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
19857 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
19858 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
19859 .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;}
19860 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
19861 .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;}
19862 .btn-sm:hover{background:var(--line);}
19863 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
19864 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
19865 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
19866 .bug-report-hint a:hover{text-decoration:underline;}
19867 .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;}
19868 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
19869 .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;}
19870 .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;}
19871 .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;}
19872 @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));}}
19873 .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;}
19874 </style>
19875</head>
19876<body>
19877 <div class="background-watermarks" aria-hidden="true">
19878 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19879 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19880 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19881 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19882 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19883 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19884 </div>
19885 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19886 <div class="top-nav">
19887 <div class="top-nav-inner">
19888 <a class="brand" href="/">
19889 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
19890 <div class="brand-copy">
19891 <div class="brand-title">OxideSLOC</div>
19892 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
19893 </div>
19894 </a>
19895 <div class="nav-right">
19896 <a class="nav-pill" href="/">Home</a>
19897 <div class="nav-dropdown">
19898 <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>
19899 <div class="nav-dropdown-menu">
19900 <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>
19901 </div>
19902 </div>
19903 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19904 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19905 <div class="nav-dropdown">
19906 <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>
19907 <div class="nav-dropdown-menu">
19908 <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>
19909 </div>
19910 </div>
19911 <div class="server-status-wrap" id="server-status-wrap">
19912 <div class="nav-pill server-online-pill" id="server-status-pill">
19913 <span class="status-dot" id="status-dot"></span>
19914 <span id="server-status-label">Server</span>
19915 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19916 </div>
19917 <div class="server-status-tip">
19918 OxideSLOC is running — accessible on your network.
19919 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19920 </div>
19921 </div>
19922 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19923 <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>
19924 </button>
19925 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19926 <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>
19927 <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>
19928 </button>
19929 </div>
19930 </div>
19931 </div>
19932
19933 <div class="page">
19934 <div class="panel">
19935 <h1>Error</h1>
19936 <div class="error-box" id="error-msg-text">{{ message }}</div>
19937 <div id="br-meta" hidden
19938 data-version="{{ version }}"
19939 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
19940 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
19941 <div class="actions">
19942 <a class="btn-primary" href="/scan">Back to setup</a>
19943 {% if let Some(report_url) = last_report_url %}
19944 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
19945 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
19946 {% else %}
19947 <a class="btn-secondary" href="/view-reports">View Reports</a>
19948 {% endif %}
19949 </div>
19950 <div class="bug-report-section" id="bug-report-section">
19951 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
19952 <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>
19953 Generate Bug Report
19954 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19955 </button>
19956 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
19957 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
19958 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
19959 <div class="bug-report-btns">
19960 <button type="button" class="btn-sm" id="bug-report-copy">
19961 <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>
19962 Copy to clipboard
19963 </button>
19964 <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;">
19965 <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>
19966 Open GitHub Issue
19967 </a>
19968 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
19969 <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>
19970 Save as file
19971 </button>
19972 </div>
19973 <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>
19974 <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>
19975 </div>
19976 </div>
19977 </div>
19978 </div>
19979 <footer class="site-footer">
19980 oxide-sloc v{{ version }} — local code metrics workbench ·
19981 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19982 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19983 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19984 · <a href="/api-docs" rel="noopener">REST API</a>
19985 </footer>
19986 <script nonce="{{ csp_nonce }}">(function(){
19987 var meta=document.getElementById('br-meta');
19988 var pre=document.getElementById('bug-report-pre');
19989 var copyBtn=document.getElementById('bug-report-copy');
19990 var trigger=document.getElementById('bug-report-trigger');
19991 var panel=document.getElementById('bug-report-panel');
19992 var networkBadge=document.getElementById('br-network-badge');
19993 var networkLabel=document.getElementById('br-network-label');
19994 var ghLink=document.getElementById('bug-report-github-link');
19995 var saveBtn=document.getElementById('bug-report-save');
19996 var hintOnline=document.getElementById('br-hint-online');
19997 var hintOffline=document.getElementById('br-hint-offline');
19998 if(!meta||!pre)return;
19999 var ver=meta.getAttribute('data-version')||'';
20000 var runId=meta.getAttribute('data-run-id')||'';
20001 var code=meta.getAttribute('data-error-code')||'';
20002 var msgEl=document.getElementById('error-msg-text');
20003 var msg=msgEl?msgEl.textContent.trim():'';
20004 function getBrowser(){
20005 var ua=navigator.userAgent;
20006 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
20007 if(!m)return 'Unknown browser';
20008 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
20009 return n+' '+m[2];
20010 }
20011 var lines=['oxide-sloc Bug Report','==============================',''];
20012 lines.push('App version: v'+ver);
20013 if(code)lines.push('HTTP status: '+code);
20014 if(runId)lines.push('Run ID: '+runId);
20015 lines.push('Page: '+window.location.pathname+(window.location.search||''));
20016 lines.push('Timestamp: '+new Date().toISOString());
20017 lines.push('Browser: '+getBrowser());
20018 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
20019 lines.push('');
20020 lines.push('Error message:');
20021 lines.push(msg);
20022 lines.push('');
20023 lines.push('Steps to reproduce:');
20024 lines.push(' 1. ');
20025 lines.push('');
20026 lines.push('Expected behavior:');
20027 lines.push(' ');
20028 pre.textContent=lines.join('\n');
20029 function applyNetwork(online){
20030 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
20031 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
20032 if(ghLink){
20033 if(online){
20034 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
20035 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
20036 }
20037 ghLink.style.display=online?'inline-flex':'none';
20038 }
20039 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
20040 if(hintOnline)hintOnline.style.display=online?'block':'none';
20041 if(hintOffline)hintOffline.style.display=online?'none':'block';
20042 }
20043 applyNetwork(navigator.onLine);
20044 var probed=false;
20045 function probeNetwork(){
20046 if(probed)return;probed=true;
20047 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
20048 var probeIdx=0;
20049 function tryNext(){
20050 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
20051 var u=probeUrls[probeIdx++];
20052 var c2=new AbortController();
20053 var t2=setTimeout(function(){c2.abort();},4000);
20054 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
20055 .then(function(){clearTimeout(t2);applyNetwork(true);})
20056 .catch(function(){clearTimeout(t2);tryNext();});
20057 }
20058 tryNext();
20059 }
20060 if(trigger&&panel){
20061 trigger.addEventListener('click',function(){
20062 var open=panel.classList.toggle('open');
20063 trigger.classList.toggle('open',open);
20064 trigger.setAttribute('aria-expanded',open?'true':'false');
20065 if(open)probeNetwork();
20066 });
20067 }
20068 if(copyBtn){
20069 copyBtn.addEventListener('click',function(){
20070 var txt=pre.textContent;
20071 if(navigator.clipboard&&navigator.clipboard.writeText){
20072 navigator.clipboard.writeText(txt).then(function(){
20073 copyBtn.textContent='✓ Copied!';
20074 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);
20075 });
20076 }else{
20077 var ta=document.createElement('textarea');
20078 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
20079 document.body.appendChild(ta);ta.select();
20080 try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
20081 document.body.removeChild(ta);
20082 }
20083 });
20084 }
20085 if(saveBtn){
20086 saveBtn.addEventListener('click',function(){
20087 var txt=pre.textContent;
20088 var blob=new Blob([txt],{type:'text/plain'});
20089 var url=URL.createObjectURL(blob);
20090 var a=document.createElement('a');
20091 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
20092 document.body.appendChild(a);a.click();
20093 document.body.removeChild(a);URL.revokeObjectURL(url);
20094 });
20095 }
20096 })();</script>
20097 <script nonce="{{ csp_nonce }}">
20098 (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");});})();
20099 (function spawnCodeParticles() {
20100 var container = document.getElementById('code-particles');
20101 if (!container) return;
20102 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'];
20103 for (var i = 0; i < 38; i++) {
20104 (function(idx) {
20105 var el = document.createElement('span');
20106 el.className = 'code-particle';
20107 el.textContent = snippets[idx % snippets.length];
20108 var left = Math.random() * 94 + 2;
20109 var top = Math.random() * 88 + 6;
20110 var dur = (Math.random() * 10 + 9).toFixed(1);
20111 var delay = (Math.random() * 18).toFixed(1);
20112 var rot = (Math.random() * 26 - 13).toFixed(1);
20113 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20114 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';
20115 container.appendChild(el);
20116 })(i);
20117 }
20118 })();
20119 (function randomizeWatermarks() {
20120 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20121 var placed = [];
20122 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; }
20123 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]; }
20124 var half = Math.floor(wms.length/2);
20125 wms.forEach(function(img, i) {
20126 var pos = pick(i < half);
20127 var w = Math.floor(Math.random()*60+80);
20128 var rot = (Math.random()*40-20).toFixed(1);
20129 var op = (Math.random()*0.08+0.05).toFixed(2);
20130 var animDur = (Math.random()*6+5).toFixed(1);
20131 var animDelay = (Math.random()*10).toFixed(1);
20132 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';
20133 });
20134 })();
20135 </script>
20136 <script nonce="{{ csp_nonce }}">
20137 (function(){
20138 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'}];
20139 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);});}
20140 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20141 function init(){
20142 var btn=document.getElementById('settings-btn');if(!btn)return;
20143 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20144 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>';
20145 document.body.appendChild(m);
20146 var g=document.getElementById('scheme-grid');
20147 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);});
20148 var cl=document.getElementById('settings-close');
20149 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);
20150 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');});
20151 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20152 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20153 }
20154 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20155 }());
20156 </script>
20157 <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>
20158</body>
20159</html>
20160"##,
20161 ext = "html"
20162)]
20163struct ErrorTemplate {
20164 message: String,
20165 last_report_url: Option<String>,
20167 last_report_label: Option<String>,
20169 run_id: Option<String>,
20171 error_code: Option<u16>,
20173 csp_nonce: String,
20174 version: &'static str,
20175}
20176
20177#[derive(Template)]
20180#[template(
20181 source = r##"
20182<!doctype html>
20183<html lang="en">
20184<head>
20185 <meta charset="utf-8">
20186 <meta name="viewport" content="width=device-width, initial-scale=1">
20187 <title>OxideSLOC | Locate Report</title>
20188 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20189 <style nonce="{{ csp_nonce }}">
20190 :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);}
20191 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
20192 *{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;}
20193 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20194 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20195 .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);}
20196 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20197 .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));}
20198 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20199 .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;}
20200 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20201 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20202 @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;}}
20203 .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;}
20204 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20205 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20206 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20207 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20208 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
20209 .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;}
20210 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20211 .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);}
20212 .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;}
20213 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20214 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20215 .settings-modal-body{padding:14px 16px 16px;}
20216 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20217 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20218 .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;}
20219 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20220 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20221 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20222 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20223 .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;}
20224 .tz-select:focus{border-color:var(--oxide);}
20225 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20226 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20227 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20228 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
20229 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
20230 .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;}
20231 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
20232 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20233 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20234 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20235 .locate-row{display:flex;gap:8px;align-items:stretch;}
20236 .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;}
20237 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20238 body.dark-theme .locate-input{background:var(--surface-2);}
20239 .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;}
20240 .warning-banner.show{display:flex;}
20241 .warning-banner svg{flex:0 0 auto;}
20242 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
20243 .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;}
20244 .error-inline.show{display:flex;}
20245 .error-inline svg{flex:0 0 auto;margin-top:2px;}
20246 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
20247 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
20248 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
20249 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
20250 .err-kv-p{margin:0 0 4px;}
20251 .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;}
20252 .success-inline.show{display:flex;}
20253 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20254 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
20255 .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;}
20256 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
20257 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
20258 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
20259 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
20260 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
20261 .fh-row:last-child{border-bottom:none;}
20262 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
20263 .fh-dir{font-weight:800;color:var(--text);}
20264 .fh-hl{color:var(--oxide);font-weight:700;}
20265 .fh-muted{color:var(--muted);}
20266 .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;}
20267 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
20268 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
20269 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
20270 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
20271 .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;}
20272 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
20273 .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;}
20274 .btn-secondary:hover{background:var(--line);}
20275 .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;}
20276 .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;}
20277 .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;}
20278 @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));}}
20279 .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;}
20280 .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;}
20281 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
20282 </style>
20283</head>
20284<body>
20285 <div class="background-watermarks" aria-hidden="true">
20286 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20287 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20288 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20289 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20290 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20291 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20292 </div>
20293 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20294 <div class="top-nav">
20295 <div class="top-nav-inner">
20296 <a class="brand" href="/">
20297 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20298 <div class="brand-copy">
20299 <div class="brand-title">OxideSLOC</div>
20300 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20301 </div>
20302 </a>
20303 <div class="nav-right">
20304 <a class="nav-pill" href="/">Home</a>
20305 <div class="nav-dropdown">
20306 <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>
20307 <div class="nav-dropdown-menu">
20308 <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>
20309 </div>
20310 </div>
20311 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20312 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20313 <div class="nav-dropdown">
20314 <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>
20315 <div class="nav-dropdown-menu">
20316 <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>
20317 </div>
20318 </div>
20319 <div class="server-status-wrap" id="server-status-wrap">
20320 <div class="nav-pill server-online-pill" id="server-status-pill">
20321 <span class="status-dot" id="status-dot"></span>
20322 <span id="server-status-label">Server</span>
20323 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20324 </div>
20325 <div class="server-status-tip">
20326 OxideSLOC is running — accessible on your network.
20327 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20328 </div>
20329 </div>
20330 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20331 <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>
20332 </button>
20333 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20334 <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>
20335 <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>
20336 </button>
20337 </div>
20338 </div>
20339 </div>
20340
20341 <div class="page">
20342 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
20343 <div class="panel">
20344 <h1>Report File Not Found</h1>
20345 <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>
20346 <div class="field-label">Missing file</div>
20347 <div class="filename-chip">
20348 <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>
20349 {{ expected_filename }}
20350 </div>
20351 <div class="locate-section">
20352 <h2>Locate Scan Output Folder</h2>
20353 <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>
20354 <p>OxideSLOC will find the correct files inside automatically.</p>
20355 <div class="locate-row">
20356 <input type="text" id="locate-file-input"
20357 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
20358 class="locate-input" autocomplete="off" spellcheck="false">
20359 {% if !server_mode %}
20360 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
20361 {% endif %}
20362 </div>
20363 <div class="warning-banner" id="filename-warning">
20364 <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>
20365 <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>
20366 </div>
20367 <div class="error-inline" id="locate-error">
20368 <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>
20369 <span id="locate-error-text"></span>
20370 </div>
20371 <div class="success-inline" id="locate-success">
20372 <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>
20373 <span>Scan restored — loading report…</span>
20374 </div>
20375 <div class="btn-row">
20376 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
20377 <a class="btn-secondary" href="/view-reports">View Reports</a>
20378 </div>
20379 <div class="folder-hint-shell">
20380 <div class="folder-hint-hdr">
20381 <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>
20382 Expected Folder Structure — Select the Top-Level Folder
20383 </div>
20384 <div class="folder-hint-body">
20385 <div class="fh-row">
20386 <span class="fh-tog">►</span>
20387 <span class="fh-dir">project_20260601-0029-…/</span>
20388 <span class="fh-badge">← select this</span>
20389 </div>
20390 <div class="fh-row fh-i1">
20391 <span class="fh-tog">►</span>
20392 <span class="fh-dir">html/</span>
20393 </div>
20394 <div class="fh-row fh-i2">
20395 <span class="fh-bul">•</span>
20396 <span class="fh-hl">{{ expected_filename }}</span>
20397 </div>
20398 <div class="fh-row fh-i1">
20399 <span class="fh-tog">►</span>
20400 <span class="fh-dir">json/</span>
20401 </div>
20402 <div class="fh-row fh-i2">
20403 <span class="fh-bul">•</span>
20404 <span class="fh-muted">result_*.json</span>
20405 </div>
20406 <div class="fh-row fh-i1">
20407 <span class="fh-tog">►</span>
20408 <span class="fh-dir">pdf/</span>
20409 </div>
20410 <div class="fh-row fh-i2">
20411 <span class="fh-bul">•</span>
20412 <span class="fh-muted">report_*.pdf</span>
20413 </div>
20414 <div class="fh-row fh-i1">
20415 <span class="fh-tog">►</span>
20416 <span class="fh-dir">excel/</span>
20417 </div>
20418 <div class="fh-row fh-i2">
20419 <span class="fh-bul">•</span>
20420 <span class="fh-muted">report_*.csv report_*.xlsx</span>
20421 </div>
20422 </div>
20423 </div>
20424 </div>
20425 </div>
20426 </div>
20427 <footer class="site-footer">
20428 oxide-sloc v{{ version }} — local code metrics workbench ·
20429 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20430 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20431 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20432 · <a href="/api-docs" rel="noopener">REST API</a>
20433 </footer>
20434 <script nonce="{{ csp_nonce }}">(function(){
20435 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
20436 if(s==="dark")b.classList.add("dark-theme");
20437 document.getElementById("theme-toggle").addEventListener("click",function(){
20438 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
20439 });
20440 })();</script>
20441 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
20442 var c=document.getElementById('code-particles');if(!c)return;
20443 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'];
20444 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);}
20445 })();
20446 (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>
20447 <script nonce="{{ csp_nonce }}">(function(){
20448 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'}];
20449 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);});}
20450 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20451 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');});}
20452 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20453 }());</script>
20454 <script nonce="{{ csp_nonce }}">(function(){
20455 var meta=document.getElementById('locate-meta');
20456 var inp=document.getElementById('locate-file-input');
20457 var browseBtn=document.getElementById('browse-locate-btn');
20458 var submitBtn=document.getElementById('locate-submit-btn');
20459 var warning=document.getElementById('filename-warning');
20460 var errBox=document.getElementById('locate-error');
20461 var errText=document.getElementById('locate-error-text');
20462 var okBox=document.getElementById('locate-success');
20463 var expected=meta?meta.getAttribute('data-expected'):'';
20464 var runId=meta?meta.getAttribute('data-run-id'):'';
20465 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
20466 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
20467 function showErr(msg){
20468 if(errText){
20469 errText.innerHTML='';
20470 var lines=msg.split('\n');
20471 var hasPairs=lines.some(function(l){return / : /.test(l);});
20472 if(!hasPairs){errText.textContent=msg;}
20473 else{
20474 var frag=document.createDocumentFragment();var tbl=null;
20475 lines.forEach(function(line){
20476 var m=line.match(/^(.*?) : (.*)$/);
20477 if(m){
20478 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
20479 var tr=document.createElement('tr');
20480 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
20481 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
20482 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
20483 } else {
20484 tbl=null;
20485 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
20486 }
20487 });
20488 errText.appendChild(frag);
20489 }
20490 }
20491 if(errBox)errBox.classList.add('show');
20492 if(okBox)okBox.classList.remove('show');
20493 }
20494 function clearErr(){
20495 if(errBox)errBox.classList.remove('show');
20496 if(okBox)okBox.classList.remove('show');
20497 }
20498 function validate(){
20499 var val=inp?inp.value.trim():'';
20500 clearErr();
20501 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
20502 if(submitBtn)submitBtn.disabled=false;
20503 if(warning){
20504 var name=basename(val);
20505 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
20506 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
20507 else warning.classList.remove('show');
20508 }
20509 }
20510 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
20511 if(browseBtn){
20512 browseBtn.addEventListener('click',function(){
20513 browseBtn.disabled=true;browseBtn.textContent='...';
20514 fetch('/pick-directory')
20515 .then(function(r){return r.ok?r.json():{cancelled:true};})
20516 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
20517 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
20518 });
20519 }
20520 if(submitBtn){
20521 submitBtn.addEventListener('click',function(){
20522 var folder=inp?inp.value.trim():'';
20523 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
20524 clearErr();
20525 submitBtn.disabled=true;submitBtn.textContent='Restoring…';
20526 var body=new URLSearchParams();
20527 body.set('file_path',folder);
20528 body.set('redirect_url',redirectUrl);
20529 body.set('expected_run_id',runId);
20530 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
20531 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
20532 .then(function(d){
20533 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20534 if(d&&d.ok){
20535 if(okBox)okBox.classList.add('show');
20536 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
20537 } else {
20538 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
20539 }
20540 })
20541 .catch(function(e){
20542 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20543 showErr('Network error: '+String(e));
20544 });
20545 });
20546 }
20547 })();</script>
20548 <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>
20549</body>
20550</html>
20551"##,
20552 ext = "html"
20553)]
20554struct LocateFileTemplate {
20555 run_id: String,
20556 artifact_type: String,
20557 expected_filename: String,
20558 server_mode: bool,
20559 csp_nonce: String,
20560 version: &'static str,
20561}
20562
20563#[derive(Template)]
20566#[template(
20567 source = r##"
20568<!doctype html>
20569<html lang="en">
20570<head>
20571 <meta charset="utf-8">
20572 <meta name="viewport" content="width=device-width, initial-scale=1">
20573 <title>OxideSLOC | Locate Scan Files</title>
20574 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20575 <style nonce="{{ csp_nonce }}">
20576 :root {
20577 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20578 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20579 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
20580 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20581 }
20582 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
20583 *{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;}
20584 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20585 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20586 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
20587 .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);}
20588 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20589 .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));}
20590 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20591 .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;}
20592 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20593 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20594 @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;}}
20595 .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;}
20596 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20597 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20598 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20599 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20600 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20601 .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;}
20602 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20603 .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);}
20604 .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;}
20605 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20606 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20607 .settings-modal-body{padding:14px 16px 16px;}
20608 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20609 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20610 .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;}
20611 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20612 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20613 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20614 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20615 .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;}
20616 .tz-select:focus{border-color:var(--oxide);}
20617 .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20618 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20619 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20620 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
20621 .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;}
20622 .error-box.hidden{display:none;}
20623 .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;}
20624 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20625 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
20626 .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;}
20627 .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;}
20628 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
20629 .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;}
20630 .btn-secondary:hover{background:var(--line);}
20631 .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;}
20632 .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;}
20633 .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;}
20634 @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));}}
20635 .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;}
20636 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20637 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20638 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20639 .relocate-row{display:flex;gap:8px;align-items:stretch;}
20640 .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;}
20641 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20642 body.dark-theme .relocate-input{background:var(--surface-2);}
20643 </style>
20644</head>
20645<body>
20646 <div class="background-watermarks" aria-hidden="true">
20647 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20648 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20649 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20650 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20651 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20652 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20653 </div>
20654 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20655 <div class="top-nav">
20656 <div class="top-nav-inner">
20657 <a class="brand" href="/">
20658 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20659 <div class="brand-copy">
20660 <div class="brand-title">OxideSLOC</div>
20661 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20662 </div>
20663 </a>
20664 <div class="nav-right">
20665 <a class="nav-pill" href="/">Home</a>
20666 <div class="nav-dropdown">
20667 <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>
20668 <div class="nav-dropdown-menu">
20669 <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>
20670 </div>
20671 </div>
20672 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
20673 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20674 <div class="nav-dropdown">
20675 <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>
20676 <div class="nav-dropdown-menu">
20677 <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>
20678 </div>
20679 </div>
20680 <div class="server-status-wrap" id="server-status-wrap">
20681 <div class="nav-pill server-online-pill" id="server-status-pill">
20682 <span class="status-dot" id="status-dot"></span>
20683 <span id="server-status-label">Server</span>
20684 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20685 </div>
20686 <div class="server-status-tip">
20687 OxideSLOC is running — accessible on your network.
20688 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20689 </div>
20690 </div>
20691 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20692 <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>
20693 </button>
20694 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20695 <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>
20696 <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>
20697 </button>
20698 </div>
20699 </div>
20700 </div>
20701
20702 <div class="page">
20703 <div class="panel">
20704 <h1>Scan Files Moved</h1>
20705 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
20706 <div class="error-box" id="relocate-error-box">{{ message }}</div>
20707 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
20708 <div class="relocate-section">
20709 <h2>Locate Scan Output</h2>
20710 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
20711 <div class="relocate-row">
20712 <input type="text" id="relocate-folder" name="folder_path"
20713 value="{{ folder_hint }}"
20714 placeholder="Path to folder containing scan output..."
20715 class="relocate-input" autocomplete="off" spellcheck="false">
20716 {% if !server_mode %}
20717 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
20718 {% endif %}
20719 </div>
20720 <div style="margin-top:12px;">
20721 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
20722 </div>
20723 </div>
20724 <div class="actions">
20725 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
20726 <a class="btn-secondary" href="/view-reports">View Reports</a>
20727 </div>
20728 </div>
20729 </div>
20730 <footer class="site-footer">
20731 oxide-sloc v{{ version }} — local code metrics workbench ·
20732 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20733 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20734 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20735 · <a href="/api-docs" rel="noopener">REST API</a>
20736 </footer>
20737 <script nonce="{{ csp_nonce }}">
20738 (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");});})();
20739 (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);}})();
20740 (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;});})();
20741 </script>
20742 <script nonce="{{ csp_nonce }}">
20743 (function(){
20744 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'}];
20745 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);});}
20746 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20747 function init(){
20748 var btn=document.getElementById('settings-btn');if(!btn)return;
20749 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20750 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>';
20751 document.body.appendChild(m);
20752 var g=document.getElementById('scheme-grid');
20753 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);});
20754 var cl=document.getElementById('settings-close');
20755 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);
20756 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');});
20757 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20758 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20759 }
20760 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20761 }());
20762 (function(){
20763 var browseBtn=document.getElementById('browse-relocate-btn');
20764 if(browseBtn){
20765 browseBtn.addEventListener('click',function(){
20766 browseBtn.disabled=true;browseBtn.textContent='...';
20767 var inp=document.getElementById('relocate-folder');
20768 var hint=inp?inp.value:'';
20769 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
20770 .then(function(r){return r.ok?r.json():{cancelled:true};})
20771 .then(function(d){
20772 browseBtn.disabled=false;browseBtn.textContent='Browse…';
20773 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
20774 })
20775 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
20776 });
20777 }
20778 var restoreBtn=document.getElementById('restore-btn');
20779 var errBox=document.getElementById('relocate-error-box');
20780 var okBox=document.getElementById('relocate-success-box');
20781 if(restoreBtn){
20782 restoreBtn.addEventListener('click',function(){
20783 var inp=document.getElementById('relocate-folder');
20784 var folder=inp?inp.value.trim():'';
20785 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
20786 restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
20787 var body=new URLSearchParams();
20788 body.set('run_id','{{ run_id }}');
20789 body.set('redirect_url','{{ redirect_url }}');
20790 body.set('folder_path',folder);
20791 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
20792 .then(function(r){return r.json();})
20793 .then(function(d){
20794 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
20795 if(d&&d.ok){
20796 if(errBox)errBox.classList.add('hidden');
20797 if(okBox){okBox.style.display='block';}
20798 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
20799 } else {
20800 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
20801 }
20802 })
20803 .catch(function(e){
20804 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
20805 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
20806 });
20807 });
20808 }
20809 }());
20810 </script>
20811 <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>
20812</body>
20813</html>
20814"##,
20815 ext = "html"
20816)]
20817struct RelocateScanTemplate {
20818 message: String,
20819 run_id: String,
20820 folder_hint: String,
20821 redirect_url: String,
20822 server_mode: bool,
20823 csp_nonce: String,
20824 version: &'static str,
20825}
20826
20827#[derive(Template)]
20830#[template(
20831 source = r##"
20832<!doctype html>
20833<html lang="en">
20834<head>
20835 <meta charset="utf-8">
20836 <meta name="viewport" content="width=device-width, initial-scale=1">
20837 <title>OxideSLOC | View Reports</title>
20838 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20839 <style nonce="{{ csp_nonce }}">
20840 :root {
20841 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
20842 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20843 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
20844 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20845 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
20846 }
20847 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; }
20848 *{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;}
20849 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20850 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20851 .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);}
20852 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20853 .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));}
20854 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20855 .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;}
20856 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20857 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20858 @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; } }
20859 .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;}
20860 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20861 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20862 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20863 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20864 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20865 .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;}
20866 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20867 .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);}
20868 .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;}
20869 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20870 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20871 .settings-modal-body{padding:14px 16px 16px;}
20872 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20873 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20874 .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;}
20875 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20876 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20877 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20878 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20879 .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;}
20880 .tz-select:focus{border-color:var(--oxide);}
20881 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
20882 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
20883 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
20884 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
20885 .panel-meta{font-size:13px;color:var(--muted);}
20886 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
20887 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
20888 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
20889 .per-page-label{font-size:13px;color:var(--muted);}
20890 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;}
20891 .filter-input{min-width:180px;cursor:text;}
20892 .table-wrap{width:100%;overflow-x:auto;}
20893 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
20894 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;}
20895 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
20896 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
20897 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
20898 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
20899 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
20900 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20901 tr:last-child td{border-bottom:none;}
20902 tr:hover td{background:var(--surface-2);}
20903 .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);}
20904 .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);}
20905 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
20906 .metric-num{font-weight:700;color:var(--text);}
20907 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
20908 .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;}
20909 .btn:hover{background:var(--line);}
20910 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20911 .btn.primary:hover{opacity:.9;}
20912 .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;}
20913 .btn-back:hover{background:var(--line);}
20914 .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;}
20915 .export-btn:hover{background:var(--line);}
20916 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
20917 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
20918 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
20919 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
20920 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
20921 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
20922 .pagination-info{font-size:13px;color:var(--muted);}
20923 .pagination-btns{display:flex;gap:6px;}
20924 .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;}
20925 .pg-btn:hover:not(:disabled){background:var(--line);}
20926 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20927 .pg-btn:disabled{opacity:.35;cursor:default;}
20928 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
20929 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
20930 .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;}
20931 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
20932 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
20933 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
20934 .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);}
20935 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
20936 .stat-chip:hover .stat-chip-tip{opacity:1;}
20937 .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;}
20938 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20939 .site-footer a{color:var(--muted);}
20940 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
20941 .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%;}
20942 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
20943 .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;}
20944 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
20945 .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;}
20946 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
20947 .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;}
20948 .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;}
20949 .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;}
20950 @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));}}
20951 .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;}
20952 .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;}
20953 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
20954 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
20955 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
20956 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
20957 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
20958 .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;}
20959 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20960 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
20961 .watched-chip-rm:hover{color:var(--oxide);}
20962 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
20963 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
20964 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
20965 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
20966 .rpt-btn{min-width:58px;justify-content:center;}
20967 .flex-row{display:flex;align-items:center;gap:8px;}
20968 .report-cell{overflow:visible;white-space:normal;}
20969 #history-table col:nth-child(1){width:185px;}
20970 #history-table col:nth-child(2){width:220px;}
20971 #history-table col:nth-child(3){width:100px;}
20972 #history-table col:nth-child(4){width:72px;}
20973 #history-table col:nth-child(5){width:82px;}
20974 #history-table col:nth-child(6){width:82px;}
20975 #history-table col:nth-child(7){width:65px;}
20976 #history-table col:nth-child(8){width:90px;}
20977 #history-table col:nth-child(9){width:85px;}
20978 #history-table col:nth-child(10){width:115px;}
20979 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
20980 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
20981 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
20982 .submod-details summary::-webkit-details-marker{display:none;}
20983.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
20984 .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;}
20985 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
20986 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
20987 </style>
20988</head>
20989<body>
20990 <div class="background-watermarks" aria-hidden="true">
20991 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20992 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20993 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20994 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20995 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20996 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20997 </div>
20998 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20999 <div class="top-nav">
21000 <div class="top-nav-inner">
21001 <a class="brand" href="/">
21002 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21003 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
21004 </a>
21005 <div class="nav-right">
21006 <a class="nav-pill" href="/">Home</a>
21007 <div class="nav-dropdown">
21008 <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>
21009 <div class="nav-dropdown-menu">
21010 <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>
21011 </div>
21012 </div>
21013 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21014 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21015 <div class="nav-dropdown">
21016 <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>
21017 <div class="nav-dropdown-menu">
21018 <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>
21019 </div>
21020 </div>
21021 <div class="server-status-wrap" id="server-status-wrap">
21022 <div class="nav-pill server-online-pill" id="server-status-pill">
21023 <span class="status-dot" id="status-dot"></span>
21024 <span id="server-status-label">Server</span>
21025 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21026 </div>
21027 <div class="server-status-tip">
21028 OxideSLOC is running — accessible on your network.
21029 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21030 </div>
21031 </div>
21032 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21033 <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>
21034 </button>
21035 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21036 <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>
21037 <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>
21038 </button>
21039 </div>
21040 </div>
21041 </div>
21042
21043 <div class="page">
21044 {% if let Some(err) = browse_error %}
21045 <div class="toast-error">
21046 <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>
21047 {{ err }}
21048 </div>
21049 {% endif %}
21050 {% if linked_count > 0 %}
21051 <div class="toast-success">
21052 <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>
21053 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
21054 </div>
21055 {% endif %}
21056 <div class="watched-bar">
21057 <div class="watched-bar-left">
21058 <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>
21059 <span class="watched-label">Watched Folders</span>
21060 <div class="watched-chips">
21061 {% if server_mode %}
21062 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
21063 {% else %}
21064 {% for dir in watched_dirs %}
21065 <span class="watched-chip">
21066 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
21067 <form method="POST" action="/watched-dirs/remove" style="display:contents">
21068 <input type="hidden" name="folder_path" value="{{ dir }}">
21069 <input type="hidden" name="redirect_to" value="/view-reports">
21070 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
21071 </form>
21072 </span>
21073 {% endfor %}
21074 {% if watched_dirs.is_empty() %}
21075 <span class="watched-none">No folders watched — click Choose to add one</span>
21076 {% endif %}
21077 {% endif %}
21078 </div>
21079 </div>
21080 {% if !server_mode %}
21081 <div class="watched-bar-right">
21082 <button type="button" class="btn" id="add-watched-btn">
21083 <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>
21084 Choose
21085 </button>
21086 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
21087 <input type="hidden" name="redirect_to" value="/view-reports">
21088 <button type="submit" class="btn">↻ Refresh</button>
21089 </form>
21090 </div>
21091 {% endif %}
21092 </div>
21093 {% if total_scans > 0 %}
21094 <div class="summary-strip">
21095 <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>
21096 <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>
21097 <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>
21098 <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>
21099 </div>
21100 {% endif %}
21101
21102 <section class="panel">
21103 <div class="panel-header">
21104 <div>
21105 <h1>View Reports</h1>
21106 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
21107 {% 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 %}
21108 </div>
21109 <div class="flex-row">
21110 <button type="button" class="export-btn" id="export-csv-btn">
21111 <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>
21112 Export CSV
21113 </button>
21114 <button type="button" class="export-btn" id="export-xls-btn">
21115 <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>
21116 Export Excel
21117 </button>
21118 </div>
21119 </div>
21120
21121 {% if entries.is_empty() %}
21122 <div class="empty-state">
21123 <strong>No reports with viewable HTML yet</strong>
21124 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.
21125 </div>
21126 {% else %}
21127 <div class="filter-row">
21128 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
21129 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
21130 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
21131 </div>
21132 <div class="table-wrap">
21133 <table id="history-table">
21134 <colgroup>
21135 <col><col><col><col><col><col><col><col><col><col>
21136 </colgroup>
21137 <thead>
21138 <tr id="history-thead">
21139 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21140 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21141 <th>Run ID<div class="col-resize-handle"></div></th>
21142 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21143 <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>
21144 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21145 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21146 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21147 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21148 <th>Report<div class="col-resize-handle"></div></th>
21149 </tr>
21150 </thead>
21151 <tbody id="history-tbody">
21152 {% for entry in entries %}
21153 <tr class="history-row" data-run="{{ entry.run_id }}"
21154 data-timestamp="{{ entry.timestamp }}"
21155 data-project="{{ entry.project_label }}"
21156 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
21157 data-skipped="{{ entry.files_skipped }}"
21158 data-comments="{{ entry.comment_lines }}"
21159 data-blank="{{ entry.blank_lines }}"
21160 data-branch="{{ entry.git_branch }}"
21161 data-commit="{{ entry.git_commit }}"
21162 data-html-url="/runs/html/{{ entry.run_id }}">
21163 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
21164 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
21165 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
21166 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
21167 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
21168 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
21169 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
21170 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
21171 <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>
21172 <td class="report-cell">
21173 <div class="actions-cell">
21174 {% 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 %}
21175 {% 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 %}
21176 </div>
21177 {% if !entry.submodule_links.is_empty() %}
21178 <details class="submod-details">
21179 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
21180 <div class="submod-link-list">
21181 {% for sub in entry.submodule_links %}
21182 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
21183 {% endfor %}
21184 </div>
21185 </details>
21186 {% endif %}
21187 </td>
21188 </tr>
21189 {% endfor %}
21190 </tbody>
21191 </table>
21192 </div>
21193 <div class="pagination">
21194 <span class="pagination-info" id="pagination-info"></span>
21195 <div class="pagination-btns" id="pagination-btns"></div>
21196 <div class="flex-row">
21197 <span class="per-page-label">Show</span>
21198 <select class="per-page" id="per-page-sel">
21199 <option value="10">10 per page</option>
21200 <option value="25" selected>25 per page</option>
21201 <option value="50">50 per page</option>
21202 <option value="100">100 per page</option>
21203 </select>
21204 <span class="per-page-label" id="page-range-label"></span>
21205 </div>
21206 </div>
21207 {% endif %}
21208 </section>
21209 </div>
21210
21211 <footer class="site-footer">
21212 local code analysis - metrics, history and reports
21213 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21214 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21215 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21216 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21217 · <a href="/api-docs" rel="noopener">REST API</a>
21218 </footer>
21219
21220 <script nonce="{{ csp_nonce }}">
21221 (function () {
21222 // ── Theme ──────────────────────────────────────────────────────────────
21223 var storageKey = 'oxide-sloc-theme';
21224 var body = document.body;
21225 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21226 var toggle = document.getElementById('theme-toggle');
21227 if (toggle) toggle.addEventListener('click', function () {
21228 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21229 body.classList.toggle('dark-theme', next === 'dark');
21230 try { localStorage.setItem(storageKey, next); } catch(e) {}
21231 });
21232
21233 // ── State ─────────────────────────────────────────────────────────────
21234 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
21235 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
21236 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
21237
21238 // Aggregate stats from first (most recent) row
21239 if (allRows.length) {
21240 var first = allRows[0];
21241 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();}
21242 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>':'');}
21243 setChipVal('agg-code', first.dataset.code);
21244 setChipVal('agg-files', first.dataset.files);
21245 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
21246 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
21247 }
21248
21249 // ── Branch filter population ──────────────────────────────────────────
21250 (function() {
21251 var branches = {};
21252 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
21253 var sel = document.getElementById('branch-filter');
21254 if (sel) Object.keys(branches).sort().forEach(function(b) {
21255 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
21256 });
21257 })();
21258
21259 // ── Filter ────────────────────────────────────────────────────────────
21260 function getFilteredRows() {
21261 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
21262 var branch = ((document.getElementById('branch-filter') || {}).value || '');
21263 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
21264 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
21265 if (branch && (r.dataset.branch || '') !== branch) return false;
21266 return true;
21267 });
21268 }
21269
21270 // ── Pagination ────────────────────────────────────────────────────────
21271 function renderPage() {
21272 var filtered = getFilteredRows();
21273 var total = filtered.length;
21274 var totalPages = Math.max(1, Math.ceil(total / perPage));
21275 currentPage = Math.min(currentPage, totalPages);
21276 var start = (currentPage - 1) * perPage;
21277 var end = Math.min(start + perPage, total);
21278 var shown = {};
21279 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
21280 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
21281 r.style.display = shown[r.dataset.run] ? '' : 'none';
21282 });
21283 var rl = document.getElementById('page-range-label');
21284 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
21285 var info = document.getElementById('pagination-info');
21286 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
21287 var btns = document.getElementById('pagination-btns');
21288 if (!btns) return;
21289 btns.innerHTML = '';
21290 function makeBtn(lbl, pg, active, disabled) {
21291 var b = document.createElement('button');
21292 b.className = 'pg-btn' + (active ? ' active' : '');
21293 b.textContent = lbl; b.disabled = disabled;
21294 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
21295 return b;
21296 }
21297 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
21298 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
21299 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
21300 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
21301 }
21302
21303 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
21304 window.applyFilters = function() { currentPage = 1; renderPage(); };
21305
21306 // ── Sorting ───────────────────────────────────────────────────────────
21307 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
21308 function doSort(col, type, order) {
21309 var tbody = document.getElementById('history-tbody');
21310 if (!tbody) return;
21311 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21312 rows.sort(function(a, b) {
21313 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
21314 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
21315 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
21316 return va < vb ? 1 : va > vb ? -1 : 0;
21317 });
21318 rows.forEach(function(r) { tbody.appendChild(r); });
21319 currentPage = 1; renderPage();
21320 }
21321 sortHeaders.forEach(function(th) {
21322 th.addEventListener('click', function(e) {
21323 if (e.target.classList.contains('col-resize-handle')) return;
21324 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
21325 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
21326 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21327 th.classList.add('sort-' + sortOrder);
21328 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
21329 doSort(col, type, sortOrder);
21330 });
21331 });
21332
21333 // ── Column resize ─────────────────────────────────────────────────────
21334 (function() {
21335 var table = document.getElementById('history-table');
21336 if (!table) return;
21337 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
21338 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
21339 ths.forEach(function(th, i) {
21340 var handle = th.querySelector('.col-resize-handle');
21341 if (!handle || !cols[i]) return;
21342 var startX, startW;
21343 handle.addEventListener('mousedown', function(e) {
21344 e.stopPropagation(); e.preventDefault();
21345 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
21346 handle.classList.add('dragging');
21347 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
21348 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
21349 document.addEventListener('mousemove', onMove);
21350 document.addEventListener('mouseup', onUp);
21351 });
21352 });
21353 })();
21354
21355 // ── Reset view ────────────────────────────────────────────────────────
21356 window.resetView = function() {
21357 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
21358 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
21359 sortCol = null; sortOrder = 'asc';
21360 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21361 var tbody = document.getElementById('history-tbody');
21362 if (tbody) {
21363 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21364 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
21365 rows.forEach(function(r) { tbody.appendChild(r); });
21366 }
21367 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
21368 var table = document.getElementById('history-table');
21369 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
21370 currentPage = 1; renderPage();
21371 };
21372
21373 renderPage();
21374
21375 // ── Export helpers ────────────────────────────────────────────────────
21376 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
21377 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
21378 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);}
21379 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;');}
21380 function slocXlsx(fname,sheet,hdrs,rows){
21381 var enc=new TextEncoder();
21382 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;}
21383 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;}
21384 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
21385 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
21386 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21387 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;}
21388 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];}
21389 var rx='<row r="1">';
21390 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
21391 rx+='</row>';
21392 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>';});
21393 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
21394 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>';
21395 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>';
21396 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>';
21397 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>',
21398 '_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>',
21399 '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>',
21400 '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>',
21401 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
21402 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'];
21403 var zparts=[],zcds=[],zoff=0,znf=0;
21404 order.forEach(function(name){
21405 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
21406 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]);
21407 var entry=new Uint8Array(lha.length+nb.length+sz);
21408 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
21409 zparts.push(entry);
21410 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));
21411 var cde=new Uint8Array(cda.length+nb.length);
21412 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
21413 zcds.push(cde);zoff+=entry.length;znf++;
21414 });
21415 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
21416 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]);
21417 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
21418 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
21419 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
21420 zout.set(new Uint8Array(ea),zpos);
21421 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
21422 }
21423
21424 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
21425 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;}
21426 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
21427 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
21428
21429 var csvBtn = document.getElementById('export-csv-btn');
21430 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
21431 var xlsBtn = document.getElementById('export-xls-btn');
21432 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
21433
21434 // ── Remaining CSP-safe event bindings ────────────────────────────────
21435 (function wireEvents() {
21436 var el;
21437 el = document.getElementById('reset-view-btn');
21438 if (el) el.addEventListener('click', window.resetView);
21439 el = document.getElementById('project-filter');
21440 if (el) el.addEventListener('input', window.applyFilters);
21441 el = document.getElementById('branch-filter');
21442 if (el) el.addEventListener('change', window.applyFilters);
21443 el = document.getElementById('per-page-sel');
21444 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
21445 el = document.getElementById('add-watched-btn');
21446 if (el) el.addEventListener('click', function() {
21447 fetch('/pick-directory?kind=reports')
21448 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
21449 .then(function(data) {
21450 if (!data.cancelled && data.selected_path) {
21451 var form = document.createElement('form');
21452 form.method = 'POST';
21453 form.action = '/watched-dirs/add';
21454 var ri = document.createElement('input');
21455 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
21456 var fi = document.createElement('input');
21457 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
21458 form.appendChild(ri); form.appendChild(fi);
21459 document.body.appendChild(form);
21460 form.submit();
21461 }
21462 })
21463 .catch(function(e) { alert('Could not open folder picker: ' + e); });
21464 });
21465 })();
21466
21467 (function randomizeWatermarks() {
21468 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21469 if (!wms.length) return;
21470 var placed = [];
21471 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;}
21472 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];}
21473 var half=Math.floor(wms.length/2);
21474 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;});
21475 })();
21476
21477 (function spawnCodeParticles() {
21478 var container = document.getElementById('code-particles');
21479 if (!container) return;
21480 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'];
21481 for (var i = 0; i < 38; i++) {
21482 (function(idx) {
21483 var el = document.createElement('span');
21484 el.className = 'code-particle';
21485 el.textContent = snippets[idx % snippets.length];
21486 var left = Math.random() * 94 + 2;
21487 var top = Math.random() * 88 + 6;
21488 var dur = (Math.random() * 10 + 9).toFixed(1);
21489 var delay = (Math.random() * 18).toFixed(1);
21490 var rot = (Math.random() * 26 - 13).toFixed(1);
21491 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21492 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';
21493 container.appendChild(el);
21494 })(i);
21495 }
21496 })();
21497 })();
21498 </script>
21499 <script nonce="{{ csp_nonce }}">
21500 (function(){
21501 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'}];
21502 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);});}
21503 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21504 function init(){
21505 var btn=document.getElementById('settings-btn');if(!btn)return;
21506 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21507 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>';
21508 document.body.appendChild(m);
21509 var g=document.getElementById('scheme-grid');
21510 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);});
21511 var cl=document.getElementById('settings-close');
21512 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);
21513 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');});
21514 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21515 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21516 }
21517 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21518 }());
21519 </script>
21520 <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>
21521</body>
21522</html>
21523"##,
21524 ext = "html"
21525)]
21526struct HistoryTemplate {
21527 version: &'static str,
21528 entries: Vec<HistoryEntryRow>,
21529 total_scans: usize,
21530 linked_count: usize,
21531 browse_error: Option<String>,
21532 watched_dirs: Vec<String>,
21533 csp_nonce: String,
21534 server_mode: bool,
21535}
21536
21537#[derive(Template)]
21540#[template(
21541 source = r##"
21542<!doctype html>
21543<html lang="en">
21544<head>
21545 <meta charset="utf-8">
21546 <meta name="viewport" content="width=device-width, initial-scale=1">
21547 <title>OxideSLOC | Compare Scans</title>
21548 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21549 <style nonce="{{ csp_nonce }}">
21550 :root {
21551 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
21552 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21553 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21554 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21555 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
21556 }
21557 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
21558 *{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;}
21559 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21560 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21561 .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);}
21562 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21563 .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));}
21564 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21565 .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;}
21566 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21567 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21568 @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; } }
21569 .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;}
21570 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21571 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
21572 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21573 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21574 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21575 .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;}
21576 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21577 .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);}
21578 .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;}
21579 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21580 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21581 .settings-modal-body{padding:14px 16px 16px;}
21582 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21583 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21584 .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;}
21585 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21586 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21587 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21588 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21589 .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;}
21590 .tz-select:focus{border-color:var(--oxide);}
21591 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
21592 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
21593 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
21594 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21595 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
21596 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
21597 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
21598 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
21599 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
21600 .per-page-label{font-size:13px;color:var(--muted);}
21601 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;}
21602 .filter-input{min-width:180px;cursor:text;}
21603 .table-wrap{width:100%;overflow-x:auto;}
21604 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
21605 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;}
21606 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
21607 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
21608 #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;}
21609 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
21610 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
21611 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
21612 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
21613 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
21614 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
21615 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
21616 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
21617 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
21618 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
21619 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
21620 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
21621 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21622 tr:last-child td{border-bottom:none;}
21623 tr.selected td{background:var(--sel-bg);}
21624 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
21625 tr:hover:not(.selected) td{background:var(--surface-2);}
21626 tr{cursor:pointer;}
21627 .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);}
21628 .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);}
21629 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
21630 .metric-num{font-weight:700;color:var(--text);}
21631 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
21632 .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;}
21633 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
21634 .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;}
21635 .btn:hover{background:var(--line);}
21636 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
21637 .btn.primary:hover{opacity:.9;}
21638 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
21639 .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;}
21640 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
21641 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
21642 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
21643 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
21644 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
21645 .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;}
21646 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21647 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
21648 .watched-chip-rm:hover{color:var(--oxide);}
21649 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
21650 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
21651 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
21652 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
21653 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
21654 .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;}
21655 .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;}
21656 .btn-back:hover{background:var(--line);}
21657 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
21658 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
21659 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
21660 .pagination-info{font-size:13px;color:var(--muted);}
21661 .pagination-btns{display:flex;gap:6px;}
21662 .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;}
21663 .pg-btn:hover:not(:disabled){background:var(--line);}
21664 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21665 .pg-btn:disabled{opacity:.35;cursor:default;}
21666 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
21667 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21668 .site-footer a{color:var(--muted);}
21669 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
21670 .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;}
21671 .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;}
21672 .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;}
21673 @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));}}
21674 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
21675 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
21676 .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;}
21677 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
21678 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
21679 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
21680 .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);}
21681 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
21682 .stat-chip:hover .stat-chip-tip{opacity:1;}
21683 .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;}
21684 .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;}
21685 .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%;}
21686 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
21687 .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;}
21688 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
21689 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
21690 .hidden{display:none!important;}
21691 .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%;}
21692 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
21693 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
21694 .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;}
21695 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
21696 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
21697 .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;}
21698 .scope-option:hover{background:var(--line);}
21699 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
21700 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
21701 .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;}
21702 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
21703 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
21704 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
21705 .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;}
21706 </style>
21707</head>
21708<body>
21709 <div class="background-watermarks" aria-hidden="true">
21710 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21711 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21712 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21713 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21714 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21715 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21716 </div>
21717 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21718 <div class="top-nav">
21719 <div class="top-nav-inner">
21720 <a class="brand" href="/">
21721 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21722 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
21723 </a>
21724 <div class="nav-right">
21725 <a class="nav-pill" href="/">Home</a>
21726 <div class="nav-dropdown">
21727 <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>
21728 <div class="nav-dropdown-menu">
21729 <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>
21730 </div>
21731 </div>
21732 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21733 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21734 <div class="nav-dropdown">
21735 <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>
21736 <div class="nav-dropdown-menu">
21737 <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>
21738 </div>
21739 </div>
21740 <div class="server-status-wrap" id="server-status-wrap">
21741 <div class="nav-pill server-online-pill" id="server-status-pill">
21742 <span class="status-dot" id="status-dot"></span>
21743 <span id="server-status-label">Server</span>
21744 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21745 </div>
21746 <div class="server-status-tip">
21747 OxideSLOC is running — accessible on your network.
21748 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21749 </div>
21750 </div>
21751 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21752 <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>
21753 </button>
21754 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21755 <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>
21756 <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>
21757 </button>
21758 </div>
21759 </div>
21760 </div>
21761
21762 <div class="page">
21763 <div class="watched-bar">
21764 <div class="watched-bar-left">
21765 <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>
21766 <span class="watched-label">Watched Folders</span>
21767 <div class="watched-chips">
21768 {% if server_mode %}
21769 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
21770 {% else %}
21771 {% for dir in watched_dirs %}
21772 <span class="watched-chip">
21773 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
21774 <form method="POST" action="/watched-dirs/remove" style="display:contents">
21775 <input type="hidden" name="folder_path" value="{{ dir }}">
21776 <input type="hidden" name="redirect_to" value="/compare-scans">
21777 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
21778 </form>
21779 </span>
21780 {% endfor %}
21781 {% if watched_dirs.is_empty() %}
21782 <span class="watched-none">No folders watched — click Choose to add one</span>
21783 {% endif %}
21784 {% endif %}
21785 </div>
21786 </div>
21787 {% if !server_mode %}
21788 <div class="watched-bar-right">
21789 <button type="button" class="btn" id="add-watched-btn">
21790 <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>
21791 Choose
21792 </button>
21793 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
21794 <input type="hidden" name="redirect_to" value="/compare-scans">
21795 <button type="submit" class="btn">↻ Refresh</button>
21796 </form>
21797 </div>
21798 {% endif %}
21799 </div>
21800 {% if total_scans > 0 %}
21801 <div class="summary-strip">
21802 <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>
21803 <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>
21804 <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>
21805 <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>
21806 </div>
21807 {% endif %}
21808 <section class="panel">
21809 <div class="panel-header">
21810 <div>
21811 <h1>Compare Scans</h1>
21812 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
21813 </div>
21814 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
21815 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
21816 <button class="btn primary" id="compare-btn" disabled>
21817 <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>
21818 Compare <span class="sel-count" id="sel-count">0/2</span>
21819 </button>
21820 </div>
21821 </div>
21822 </div>
21823
21824 {% if entries.is_empty() %}
21825 <div class="empty-state">
21826 <strong>No scans yet</strong>
21827 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.
21828 </div>
21829 {% else %}
21830 <div class="filter-row">
21831 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
21832 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
21833 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
21834 </div>
21835 <div class="scope-panel hidden" id="scope-panel">
21836 <div class="scope-panel-label">
21837 <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>
21838 Compare scope — choose what to include
21839 </div>
21840 <div class="scope-options" id="scope-options"></div>
21841 </div>
21842 {% if total_scans > 0 %}
21843 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
21844 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
21845 <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>
21846 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
21847 </div>
21848 </div>
21849 {% endif %}
21850 <div class="table-wrap">
21851 <table id="compare-table">
21852 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
21853 <thead>
21854 <tr id="compare-thead">
21855 <th><div class="col-resize-handle"></div></th>
21856 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21857 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21858 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
21859 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21860 <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>
21861 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21862 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21863 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21864 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
21865 <th>Submodules<div class="col-resize-handle"></div></th>
21866 </tr>
21867 </thead>
21868 <tbody id="compare-tbody">
21869 {% for entry in entries %}
21870 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
21871 data-timestamp="{{ entry.timestamp }}"
21872 data-project="{{ entry.project_label }}"
21873 data-files="{{ entry.files_analyzed }}"
21874 data-code="{{ entry.code_lines }}"
21875 data-comments="{{ entry.comment_lines }}"
21876 data-blank="{{ entry.blank_lines }}"
21877 data-branch="{{ entry.git_branch }}"
21878 data-commit="{{ entry.git_commit }}"
21879 data-submodules="{{ entry.submodule_names_csv }}">
21880 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
21881 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
21882 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
21883 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
21884 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
21885 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
21886 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
21887 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
21888 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
21889 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
21890 <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>
21891 </tr>
21892 {% endfor %}
21893 </tbody>
21894 </table>
21895 </div>
21896 <div class="pagination">
21897 <span class="pagination-info" id="pagination-info"></span>
21898 <div class="pagination-btns" id="pagination-btns"></div>
21899 <div class="flex-row">
21900 <span class="per-page-label">Show</span>
21901 <select class="per-page" id="per-page-sel">
21902 <option value="10">10 per page</option>
21903 <option value="25" selected>25 per page</option>
21904 <option value="50">50 per page</option>
21905 <option value="100">100 per page</option>
21906 </select>
21907 <span class="per-page-label" id="page-range-label"></span>
21908 </div>
21909 </div>
21910 {% endif %}
21911 </section>
21912 </div>
21913
21914 <footer class="site-footer">
21915 local code analysis - metrics, history and reports
21916 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21917 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21918 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21919 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21920 · <a href="/api-docs" rel="noopener">REST API</a>
21921 </footer>
21922
21923 <script nonce="{{ csp_nonce }}">
21924 (function () {
21925 // ── Theme ──────────────────────────────────────────────────────────────
21926 var storageKey = 'oxide-sloc-theme';
21927 var body = document.body;
21928 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21929 var toggle = document.getElementById('theme-toggle');
21930 if (toggle) toggle.addEventListener('click', function () {
21931 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21932 body.classList.toggle('dark-theme', next === 'dark');
21933 try { localStorage.setItem(storageKey, next); } catch(e) {}
21934 });
21935
21936 // ── State ─────────────────────────────────────────────────────────────
21937 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
21938 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
21939 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
21940
21941 // ── Stat chips ────────────────────────────────────────────────────────
21942 (function() {
21943 var projects = {}, latestTs = '', latestRow = null;
21944 allRows.forEach(function(r) {
21945 var p = r.dataset.project || ''; if (p) projects[p] = true;
21946 var ts = r.dataset.timestamp || '';
21947 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
21948 });
21949 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();}
21950 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>':'');}
21951 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
21952 if (latestRow) {
21953 setChipVal('agg-code', latestRow.dataset.code);
21954 setChipVal('agg-files', latestRow.dataset.files);
21955 }
21956 })();
21957
21958 // ── Branch filter population ──────────────────────────────────────────
21959 (function() {
21960 var branches = {};
21961 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
21962 var sel = document.getElementById('branch-filter');
21963 if (sel) Object.keys(branches).sort().forEach(function(b) {
21964 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
21965 });
21966 })();
21967
21968 // ── Filter ────────────────────────────────────────────────────────────
21969 function getFilteredRows() {
21970 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
21971 var branch = ((document.getElementById('branch-filter') || {}).value || '');
21972 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
21973 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
21974 if (branch && (r.dataset.branch || '') !== branch) return false;
21975 return true;
21976 });
21977 }
21978
21979 // ── Pagination ────────────────────────────────────────────────────────
21980 function renderPage() {
21981 var filtered = getFilteredRows();
21982 var total = filtered.length;
21983 var totalPages = Math.max(1, Math.ceil(total / perPage));
21984 currentPage = Math.min(currentPage, totalPages);
21985 var start = (currentPage - 1) * perPage;
21986 var end = Math.min(start + perPage, total);
21987 var shown = {};
21988 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
21989 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
21990 r.style.display = shown[r.dataset.run] ? '' : 'none';
21991 });
21992 var rl = document.getElementById('page-range-label');
21993 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
21994 var info = document.getElementById('pagination-info');
21995 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
21996 var btns = document.getElementById('pagination-btns');
21997 if (!btns) return;
21998 btns.innerHTML = '';
21999 function makeBtn(lbl, pg, active, disabled) {
22000 var b = document.createElement('button');
22001 b.className = 'pg-btn' + (active ? ' active' : '');
22002 b.textContent = lbl; b.disabled = disabled;
22003 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
22004 return b;
22005 }
22006 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
22007 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
22008 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
22009 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
22010 }
22011
22012 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
22013 window.applyFilters = function() { currentPage = 1; renderPage(); };
22014
22015 // ── Sorting ───────────────────────────────────────────────────────────
22016 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
22017 function doSort(col, type, order) {
22018 var tbody = document.getElementById('compare-tbody');
22019 if (!tbody) return;
22020 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22021 rows.sort(function(a, b) {
22022 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
22023 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
22024 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
22025 return va < vb ? 1 : va > vb ? -1 : 0;
22026 });
22027 rows.forEach(function(r) { tbody.appendChild(r); });
22028 currentPage = 1; renderPage();
22029 }
22030 sortHeaders.forEach(function(th) {
22031 th.addEventListener('click', function(e) {
22032 if (e.target.classList.contains('col-resize-handle')) return;
22033 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
22034 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
22035 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22036 th.classList.add('sort-' + sortOrder);
22037 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
22038 doSort(col, type, sortOrder);
22039 });
22040 });
22041
22042 // Apply default sort (timestamp desc) on initial load
22043 (function() {
22044 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
22045 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
22046 })();
22047
22048 // ── Column resize ─────────────────────────────────────────────────────
22049 (function() {
22050 var table = document.getElementById('compare-table');
22051 if (!table) return;
22052 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
22053 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
22054 ths.forEach(function(th, i) {
22055 var handle = th.querySelector('.col-resize-handle');
22056 if (!handle || !cols[i]) return;
22057 var startX, startW;
22058 handle.addEventListener('mousedown', function(e) {
22059 e.stopPropagation(); e.preventDefault();
22060 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
22061 handle.classList.add('dragging');
22062 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
22063 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
22064 document.addEventListener('mousemove', onMove);
22065 document.addEventListener('mouseup', onUp);
22066 });
22067 });
22068 })();
22069
22070 // ── Reset view ────────────────────────────────────────────────────────
22071 window.resetView = function() {
22072 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
22073 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
22074 sortCol = null; sortOrder = 'asc';
22075 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22076 var tbody = document.getElementById('compare-tbody');
22077 if (tbody) {
22078 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22079 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
22080 rows.forEach(function(r) { tbody.appendChild(r); });
22081 }
22082 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
22083 var table = document.getElementById('compare-table');
22084 currentPage = 1; renderPage();
22085 currentPage = 1; renderPage();
22086 };
22087
22088 renderPage();
22089
22090 // ── Row selection state ───────────────────────────────────────────────
22091 var selected = [];
22092 function updateCompareBtn() {
22093 var btn = document.getElementById('compare-btn');
22094 var cnt = document.getElementById('sel-count');
22095 if (!btn) return;
22096 btn.disabled = selected.length !== 2;
22097 if (cnt) cnt.textContent = selected.length + '/2';
22098 }
22099
22100 function toggleRow(row) {
22101 var vid = row.dataset.vid || row.dataset.run;
22102 var idx = selected.indexOf(vid);
22103 if (idx >= 0) {
22104 selected.splice(idx, 1);
22105 row.classList.remove('selected');
22106 var b = document.getElementById('badge-' + vid);
22107 if (b) b.textContent = '';
22108 } else {
22109 if (selected.length >= 2) return;
22110 selected.push(vid);
22111 row.classList.add('selected');
22112 }
22113 selected.forEach(function(v, i) {
22114 var b = document.getElementById('badge-' + v);
22115 if (b) b.textContent = i + 1;
22116 });
22117 updateCompareBtn();
22118 buildScopePanel();
22119 }
22120
22121 // ── Scope panel ───────────────────────────────────────────────────────
22122 var selectedScope = 'all';
22123
22124 function buildScopePanel() {
22125 var panel = document.getElementById('scope-panel');
22126 var opts = document.getElementById('scope-options');
22127 if (!panel || !opts) return;
22128 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22129
22130 // Collect union of submodules from both selected rows.
22131 var allSubs = {};
22132 selected.forEach(function(vid) {
22133 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
22134 if (!row) return;
22135 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
22136 });
22137 var subList = Object.keys(allSubs).sort();
22138 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22139
22140 panel.classList.remove('hidden');
22141 opts.innerHTML = '';
22142
22143 function makeOption(value, label, title) {
22144 var div = document.createElement('div');
22145 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
22146 div.dataset.scopeValue = value;
22147 if (title) div.title = title;
22148 var radio = document.createElement('span');
22149 radio.className = 'scope-option-radio';
22150 var lbl = document.createElement('span');
22151 lbl.textContent = label;
22152 div.appendChild(radio);
22153 div.appendChild(lbl);
22154 div.addEventListener('click', function() {
22155 selectedScope = value;
22156 opts.querySelectorAll('.scope-option').forEach(function(o) {
22157 o.classList.toggle('selected', o.dataset.scopeValue === value);
22158 });
22159 });
22160 return div;
22161 }
22162
22163 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
22164 var sep = document.createElement('span');
22165 sep.className = 'scope-option-sep';
22166 opts.appendChild(sep);
22167 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
22168 subList.forEach(function(s) {
22169 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
22170 });
22171 }
22172
22173 function doCompare() {
22174 if (selected.length !== 2) return;
22175 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
22176 if (selectedScope === 'super') url += '&scope=super';
22177 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
22178 window.location.href = url;
22179 }
22180
22181 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
22182 var cbtn = document.getElementById('compare-btn');
22183 if (cbtn) cbtn.addEventListener('click', doCompare);
22184 var pfEl = document.getElementById('project-filter');
22185 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
22186 var bfEl = document.getElementById('branch-filter');
22187 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
22188 var rvBtn = document.getElementById('reset-view-btn');
22189 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
22190 var ppSel = document.getElementById('per-page-sel');
22191 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
22192
22193 var cmpTbody = document.getElementById('compare-tbody');
22194 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
22195 var row = e.target.closest('.compare-row');
22196 if (row) toggleRow(row);
22197 });
22198
22199 (function randomizeWatermarks() {
22200 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22201 if (!wms.length) return;
22202 var placed = [];
22203 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;}
22204 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];}
22205 var half=Math.floor(wms.length/2);
22206 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;});
22207 })();
22208
22209 (function spawnCodeParticles() {
22210 var container = document.getElementById('code-particles');
22211 if (!container) return;
22212 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'];
22213 for (var i = 0; i < 38; i++) {
22214 (function(idx) {
22215 var el = document.createElement('span');
22216 el.className = 'code-particle';
22217 el.textContent = snippets[idx % snippets.length];
22218 var left = Math.random() * 94 + 2;
22219 var top = Math.random() * 88 + 6;
22220 var dur = (Math.random() * 10 + 9).toFixed(1);
22221 var delay = (Math.random() * 18).toFixed(1);
22222 var rot = (Math.random() * 26 - 13).toFixed(1);
22223 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22224 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';
22225 container.appendChild(el);
22226 })(i);
22227 }
22228 })();
22229
22230 // ── Watched folder picker ─────────────────────────────────────────────
22231 (function() {
22232 var btn = document.getElementById('add-watched-btn');
22233 if (!btn) return;
22234 btn.addEventListener('click', function() {
22235 fetch('/pick-directory?kind=reports')
22236 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
22237 .then(function(data) {
22238 if (!data.cancelled && data.selected_path) {
22239 var form = document.createElement('form');
22240 form.method = 'POST';
22241 form.action = '/watched-dirs/add';
22242 var ri = document.createElement('input');
22243 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
22244 var fi = document.createElement('input');
22245 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
22246 form.appendChild(ri); form.appendChild(fi);
22247 document.body.appendChild(form);
22248 form.submit();
22249 }
22250 })
22251 .catch(function(e) { alert('Could not open folder picker: ' + e); });
22252 });
22253 })();
22254
22255 // ── Submodule chip truncation ─────────────────────────────────────────
22256 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
22257 var chips = cell.querySelectorAll('.submod-chip');
22258 var MAX = 4;
22259 if (chips.length <= MAX) return;
22260 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
22261 var badge = document.createElement('span');
22262 badge.className = 'submod-overflow-badge';
22263 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
22264 badge.textContent = '+' + (chips.length - MAX) + ' more';
22265 cell.appendChild(badge);
22266 cell.style.maxHeight = 'none';
22267 });
22268 })();
22269 </script>
22270 <script nonce="{{ csp_nonce }}">
22271 (function(){
22272 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'}];
22273 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);});}
22274 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22275 function init(){
22276 var btn=document.getElementById('settings-btn');if(!btn)return;
22277 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22278 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>';
22279 document.body.appendChild(m);
22280 var g=document.getElementById('scheme-grid');
22281 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);});
22282 var cl=document.getElementById('settings-close');
22283 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);
22284 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');});
22285 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22286 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22287 }
22288 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22289 }());
22290 </script>
22291 <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>
22292</body>
22293</html>
22294"##,
22295 ext = "html"
22296)]
22297struct CompareSelectTemplate {
22298 version: &'static str,
22299 entries: Vec<HistoryEntryRow>,
22300 total_scans: usize,
22301 watched_dirs: Vec<String>,
22302 csp_nonce: String,
22303 server_mode: bool,
22304}
22305
22306#[derive(Template)]
22309#[template(
22310 source = r##"
22311<!doctype html>
22312<html lang="en">
22313<head>
22314 <meta charset="utf-8">
22315 <meta name="viewport" content="width=device-width, initial-scale=1">
22316 <title>OxideSLOC | Scan Delta</title>
22317 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22318 <style nonce="{{ csp_nonce }}">
22319 :root {
22320 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
22321 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
22322 --nav:#283790; --nav-2:#013e6b;
22323 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
22324 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
22325 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
22326 }
22327 body.dark-theme {
22328 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
22329 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
22330 }
22331 *{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;}
22332 .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);}
22333 .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;}
22334 .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));}
22335 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22336 .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;}
22337 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
22338 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22339 @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; } }
22340 .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;}
22341 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22342 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22343 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22344 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22345 .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;}
22346 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22347 .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);}
22348 .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;}
22349 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22350 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22351 .settings-modal-body{padding:14px 16px 16px;}
22352 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22353 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22354 .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;}
22355 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22356 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22357 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22358 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22359 .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;}
22360 .tz-select:focus{border-color:var(--oxide);}
22361 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
22362 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
22363 .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;}
22364 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
22365 .hero-body{display:block;}
22366 .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;}
22367 .btn-back:hover{background:var(--line);}
22368 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
22369 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
22370 .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;}
22371 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
22372 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;}
22373 .muted{color:var(--muted);font-size:14px;}
22374 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
22375 .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;}
22376 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
22377 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
22378 .vpill-arrow{font-size:20px;color:var(--muted);}
22379 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
22380 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
22381 .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;}
22382 .delta-card.delta-card-wide{padding:22px 24px;}
22383 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
22384 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
22385 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
22386 .delta-card-from{font-size:15px;color:var(--muted);}
22387 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
22388 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
22389 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
22390 .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%;}
22391 .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;}
22392 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
22393 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
22394 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
22395 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
22396 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
22397 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
22398 .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;}
22399 .meta-card-commit:hover{color:var(--oxide);}
22400 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
22401 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
22402 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
22403 .meta-value{color:var(--text);font-size:13px;}
22404 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
22405 .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;}
22406 .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);}
22407 .delta-card:hover .dc-tip{display:block;}
22408 .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;}
22409 .export-btn:hover{background:var(--line);}
22410 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
22411 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
22412 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
22413 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
22414 .delta-card-change.zero{color:var(--muted);background:transparent;}
22415 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
22416 .delta-card-pct.pos{color:var(--pos);}
22417 .delta-card-pct.neg{color:var(--neg);}
22418 .delta-card-pct.zero{color:var(--muted);}
22419 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
22420 .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;}
22421 .insight-card.insight-flag{border-color:var(--oxide);}
22422 .insight-card:hover .dc-tip{display:block;}
22423 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
22424 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
22425 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
22426 .insight-label.flag{color:var(--oxide);}
22427 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
22428 .insight-val.pos{color:var(--pos);}
22429 .insight-val.neg{color:var(--neg);}
22430 .insight-val.high{color:#c0392a;}
22431 .insight-val.med{color:#926000;}
22432 .insight-val.low{color:var(--pos);}
22433 body.dark-theme .insight-val.high{color:#ff6b6b;}
22434 body.dark-theme .insight-val.med{color:#f0c060;}
22435 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
22436 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
22437 .fc-row{display:flex;align-items:center;gap:8px;}
22438 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
22439 .fc-label{color:var(--muted);}
22440 .fc-modified .fc-count{color:#926000;}
22441 .fc-added .fc-count{color:var(--pos);}
22442 .fc-removed .fc-count{color:var(--neg);}
22443 .fc-unchanged .fc-count{color:var(--muted);}
22444 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
22445 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
22446 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
22447 .chip.modified{background:#fff2d8;color:#926000;}
22448 .chip.added{background:#e8f5ed;color:#1a8f47;}
22449 .chip.removed{background:#fdeaea;color:#b33b3b;}
22450 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
22451 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
22452 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
22453 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
22454 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
22455 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
22456 .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;}
22457 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
22458 .tab-btn:hover:not(.active){background:var(--line);}
22459 .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;}
22460 .btn-reset:hover{background:var(--line);}
22461 .table-wrap{width:100%;overflow-x:auto;}
22462 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
22463 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;}
22464 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
22465 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
22466 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
22467 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
22468 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
22469 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
22470 tr:last-child td{border-bottom:none;}
22471 tr.row-added td{background:rgba(26,143,71,0.06);}
22472 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
22473 tr.row-modified td{background:rgba(146,96,0,0.05);}
22474 tr.row-unchanged td{opacity:.6;}
22475 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
22476 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
22477 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
22478 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
22479 .status-badge.modified{background:#fff2d8;color:#926000;}
22480 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
22481 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
22482 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
22483 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
22484 .delta-val{font-weight:700;}
22485 .delta-val.pos{color:var(--pos);}
22486 .delta-val.neg{color:var(--neg);}
22487 .delta-val.zero{color:var(--muted);}
22488 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
22489 .from-to strong{color:var(--text);}
22490 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22491 .site-footer a{color:var(--muted);}
22492 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
22493 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
22494 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22495 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22496 .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;}
22497 .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;}
22498 .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;}
22499 @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));}}
22500 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
22501 .path-link:hover{color:var(--oxide-2);}
22502 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
22503 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
22504 a.vpill-id:hover{color:var(--oxide);}
22505 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
22506 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
22507 .pagination-info{font-size:13px;color:var(--muted);}
22508 .pagination-btns{display:flex;gap:6px;}
22509 .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;}
22510 .pg-btn:hover:not(:disabled){background:var(--line);}
22511 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22512 .pg-btn:disabled{opacity:.35;cursor:default;}
22513 .per-page-label{font-size:13px;color:var(--muted);}
22514 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;}
22515 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22516 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
22517 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
22518 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
22519 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
22520 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
22521 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
22522 .tab-btn.tab-unchanged{color:var(--muted);}
22523 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
22524 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
22525 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
22526 .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;}
22527 .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;}
22528 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
22529 .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;}
22530 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
22531 .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;}
22532 .submod-scope-btn:hover{background:var(--line);}
22533 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22534 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
22535 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
22536 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
22537 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
22538 body.dark-theme .ic-card{background:var(--surface-2);}
22539 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
22540 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
22541 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
22542 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
22543 #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;}
22544 </style>
22545</head>
22546<body>
22547 <div class="background-watermarks" aria-hidden="true">
22548 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22549 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22550 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22551 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22552 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22553 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22554 </div>
22555 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22556 <div class="top-nav">
22557 <div class="top-nav-inner">
22558 <a class="brand" href="/">
22559 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22560 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
22561 </a>
22562 <div class="nav-right">
22563 <a class="nav-pill" href="/">Home</a>
22564 <div class="nav-dropdown">
22565 <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>
22566 <div class="nav-dropdown-menu">
22567 <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>
22568 </div>
22569 </div>
22570 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22571 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22572 <div class="nav-dropdown">
22573 <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>
22574 <div class="nav-dropdown-menu">
22575 <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>
22576 </div>
22577 </div>
22578 <div class="server-status-wrap" id="server-status-wrap">
22579 <div class="nav-pill server-online-pill" id="server-status-pill">
22580 <span class="status-dot" id="status-dot"></span>
22581 <span id="server-status-label">Server</span>
22582 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22583 </div>
22584 <div class="server-status-tip">
22585 OxideSLOC is running — accessible on your network.
22586 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22587 </div>
22588 </div>
22589 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22590 <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>
22591 </button>
22592 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22593 <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>
22594 <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>
22595 </button>
22596 </div>
22597 </div>
22598 </div>
22599
22600 <div class="page">
22601 <section class="hero">
22602 <div class="hero-header">
22603 <div>
22604 <h1 class="delta-title">Scan Delta</h1>
22605 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
22606 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
22607 {% if let Some(sub) = active_submodule %}
22608 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
22609 {% else if super_scope_active %}
22610 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
22611 {% else %}
22612 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
22613 {% endif %}
22614 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
22615 </div>
22616 </div>
22617 <a class="btn-back" href="/compare-scans">
22618 <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>
22619 Compare Scans
22620 </a>
22621 </div>
22622 {% if has_any_submodule_data %}
22623 <div class="submod-scope-bar">
22624 <span class="submod-scope-label">
22625 <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>
22626 Scope:
22627 </span>
22628 <div class="submod-scope-divider"></div>
22629 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
22630 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
22631 title="All files — super-repo and all submodules combined">Full scan</a>
22632 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
22633 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
22634 title="Only files that are not part of any submodule">Super-repo only</a>
22635 {% for sub in submodule_options %}
22636 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
22637 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
22638 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
22639 {% endfor %}
22640 </div>
22641 {% endif %}
22642 <div class="hero-body">
22643 <div class="meta-strip">
22644 <div class="delta-card delta-card-meta">
22645 <div class="meta-card-header">
22646 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
22647 <div class="meta-card-project-col">
22648 <div class="meta-card-project">{{ project_name }}</div>
22649 {% if has_any_submodule_data %}
22650 {% if let Some(sub) = active_submodule %}
22651 <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>
22652 {% else if super_scope_active %}
22653 <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>
22654 {% else %}
22655 <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>
22656 {% endif %}
22657 {% endif %}
22658 </div>
22659 </div>
22660 {% if !baseline_git_commit.is_empty() %}
22661 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
22662 {% else %}
22663 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
22664 {% endif %}
22665 <div class="meta-card-rows">
22666 <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>
22667 <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>
22668 <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>
22669 <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>
22670 {% if let Some(tags) = baseline_git_tags %}
22671 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22672 {% endif %}
22673 </div>
22674 </div>
22675 <div class="delta-card delta-card-meta">
22676 <div class="meta-card-header">
22677 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
22678 <div class="meta-card-project-col">
22679 <div class="meta-card-project">{{ project_name }}</div>
22680 {% if has_any_submodule_data %}
22681 {% if let Some(sub) = active_submodule %}
22682 <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>
22683 {% else if super_scope_active %}
22684 <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>
22685 {% else %}
22686 <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>
22687 {% endif %}
22688 {% endif %}
22689 </div>
22690 </div>
22691 {% if !current_git_commit.is_empty() %}
22692 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
22693 {% else %}
22694 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
22695 {% endif %}
22696 <div class="meta-card-rows">
22697 <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>
22698 <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>
22699 <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>
22700 <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>
22701 {% if let Some(tags) = current_git_tags %}
22702 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22703 {% endif %}
22704 </div>
22705 </div>
22706 </div>
22707 <div class="delta-strip">
22708 <div class="delta-card">
22709 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
22710 <div class="delta-card-label">Code lines</div>
22711 <div class="delta-card-from">Before: {{ baseline_code }}</div>
22712 <div class="delta-card-to">{{ current_code }}</div>
22713 {% 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>
22714 {% 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>
22715 {% else %}<div class="delta-card-pct zero">±0%</div>
22716 {% endif %}
22717 </div>
22718 <div class="delta-card">
22719 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
22720 <div class="delta-card-label">Files analyzed</div>
22721 <div class="delta-card-from">Before: {{ baseline_files }}</div>
22722 <div class="delta-card-to">{{ current_files }}</div>
22723 {% 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>
22724 {% 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>
22725 {% else %}<div class="delta-card-pct zero">±0%</div>
22726 {% endif %}
22727 </div>
22728 <div class="delta-card">
22729 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
22730 <div class="delta-card-label">Comment lines</div>
22731 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
22732 <div class="delta-card-to">{{ current_comments }}</div>
22733 {% 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>
22734 {% 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>
22735 {% else %}<div class="delta-card-pct zero">±0%</div>
22736 {% endif %}
22737 </div>
22738 {{ coverage_delta_card|safe }}
22739 <div class="delta-card delta-card-wide">
22740 <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>
22741 <div class="delta-card-label">File changes</div>
22742 <div class="file-changes-grid">
22743 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
22744 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
22745 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
22746 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
22747 </div>
22748 </div>
22749 </div>
22750 <div class="insights-panel">
22751 <div class="insight-card">
22752 <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>
22753 <div class="insight-label">Lines Added</div>
22754 <div class="insight-val pos">+{{ code_lines_added }}</div>
22755 <div class="insight-sub">New or grown source lines</div>
22756 </div>
22757 <div class="insight-card">
22758 <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>
22759 <div class="insight-label">Lines Removed</div>
22760 <div class="insight-val neg">−{{ code_lines_removed }}</div>
22761 <div class="insight-sub">Deleted or shrunk source lines</div>
22762 </div>
22763 <div class="insight-card">
22764 <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>
22765 <div class="insight-label">Churn Rate</div>
22766 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
22767 <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>
22768 </div>
22769 {% if scope_flag %}
22770 <div class="insight-card insight-flag">
22771 <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>
22772 <div class="insight-label flag">Scope Signal</div>
22773 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
22774 <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>
22775 </div>
22776 {% endif %}
22777 </div>
22778 </div>
22779 </section>
22780
22781 <section class="panel" id="inline-charts-section">
22782 <h2>Scan Delta Charts</h2>
22783 <div class="ic-grid">
22784 <div class="ic-card">
22785 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
22786 <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>
22787 <div id="ic-c1"></div>
22788 </div>
22789 <div class="ic-card" id="ic-lang-card">
22790 <div class="ic-card-h2">Language Code Delta</div>
22791 <div id="ic-c3"></div>
22792 </div>
22793 <div class="ic-card">
22794 <div class="ic-card-h2">Delta by Metric</div>
22795 <div id="ic-c2"></div>
22796 </div>
22797 <div class="ic-card">
22798 <div class="ic-card-h2">File Change Distribution</div>
22799 <div id="ic-c4"></div>
22800 </div>
22801 </div>
22802 </section>
22803
22804 <section class="panel">
22805 <h2>File-level delta</h2>
22806 <div class="filter-tabs-row">
22807 <div class="filter-tabs">
22808 <button class="tab-btn tab-all active" data-filter="all">All</button>
22809 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
22810 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
22811 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
22812 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
22813 </div>
22814 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
22815 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
22816 <div class="export-group">
22817 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
22818 <button type="button" class="export-btn" id="delta-csv-btn">
22819 <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>
22820 CSV
22821 </button>
22822 <button type="button" class="export-btn" id="delta-xls-btn">
22823 <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>
22824 Excel
22825 </button>
22826 <button type="button" class="export-btn" id="delta-charts-btn">
22827 <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>
22828 Charts
22829 </button>
22830 </div>
22831 </div>
22832 </div>
22833
22834 <div class="table-wrap">
22835 <table id="delta-table">
22836 <colgroup>
22837 <col>
22838 <col>
22839 <col>
22840 <col>
22841 <col>
22842 <col>
22843 <col>
22844 </colgroup>
22845 <thead>
22846 <tr id="delta-thead">
22847 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22848 <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>
22849 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
22850 <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>
22851 <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>
22852 <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>
22853 <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>
22854 </tr>
22855 </thead>
22856 <tbody id="delta-tbody">
22857 {% for row in file_rows %}
22858 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
22859 data-path="{{ row.relative_path }}"
22860 data-language="{{ row.language }}"
22861 data-baseline-code="{{ row.baseline_code }}"
22862 data-current-code="{{ row.current_code }}"
22863 data-code-delta="{{ row.code_delta_str }}"
22864 data-comment-delta="{{ row.comment_delta_str }}"
22865 data-total-delta="{{ row.total_delta_str }}"
22866 data-orig-idx="">
22867 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
22868 <td class="hide-sm">{{ row.language }}</td>
22869 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
22870 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
22871 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
22872 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
22873 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
22874 </tr>
22875 {% endfor %}
22876 </tbody>
22877 </table>
22878 </div>
22879 <div class="pagination">
22880 <span class="pagination-info" id="pg-info"></span>
22881 <div class="pagination-btns" id="pg-btns"></div>
22882 <div class="flex-row">
22883 <span class="per-page-label">Show</span>
22884 <select class="per-page" id="per-page-sel">
22885 <option value="10">10 per page</option>
22886 <option value="25" selected>25 per page</option>
22887 <option value="50">50 per page</option>
22888 <option value="100">100 per page</option>
22889 </select>
22890 <span class="per-page-label" id="pg-range-label"></span>
22891 </div>
22892 </div>
22893 </section>
22894 </div>
22895
22896 <div id="ic-tt"></div>
22897
22898 <footer class="site-footer">
22899 local code analysis - metrics, history and reports
22900 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22901 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22902 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22903 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22904 · <a href="/api-docs" rel="noopener">REST API</a>
22905 </footer>
22906
22907 <script nonce="{{ csp_nonce }}">
22908 (function () {
22909 var storageKey = 'oxide-sloc-theme';
22910 var body = document.body;
22911 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22912 var toggle = document.getElementById('theme-toggle');
22913 if (toggle) toggle.addEventListener('click', function () {
22914 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22915 body.classList.toggle('dark-theme', next === 'dark');
22916 try { localStorage.setItem(storageKey, next); } catch(e) {}
22917 });
22918
22919 (function randomizeWatermarks() {
22920 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22921 if (!wms.length) return;
22922 var placed = [];
22923 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;}
22924 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];}
22925 var half=Math.floor(wms.length/2);
22926 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;});
22927 })();
22928
22929 (function spawnCodeParticles() {
22930 var container = document.getElementById('code-particles');
22931 if (!container) return;
22932 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'];
22933 for (var i = 0; i < 38; i++) {
22934 (function(idx) {
22935 var el = document.createElement('span');
22936 el.className = 'code-particle';
22937 el.textContent = snippets[idx % snippets.length];
22938 var left = Math.random() * 94 + 2;
22939 var top = Math.random() * 88 + 6;
22940 var dur = (Math.random() * 10 + 9).toFixed(1);
22941 var delay = (Math.random() * 18).toFixed(1);
22942 var rot = (Math.random() * 26 - 13).toFixed(1);
22943 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22944 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';
22945 container.appendChild(el);
22946 })(i);
22947 }
22948 })();
22949 })();
22950
22951 var activeStatusFilter = 'all';
22952 var deltaPerPage = 25, deltaCurrPage = 1;
22953
22954 function openFolder(path) {
22955 fetch('/open-path?path=' + encodeURIComponent(path))
22956 .then(function (r) { return r.json(); })
22957 .then(function (d) {
22958 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
22959 })
22960 .catch(function () {});
22961 }
22962
22963 function getDeltaFilteredRows() {
22964 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
22965 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
22966 });
22967 }
22968
22969 function renderDeltaPage() {
22970 var filtered = getDeltaFilteredRows();
22971 var total = filtered.length;
22972 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
22973 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
22974 var start = (deltaCurrPage - 1) * deltaPerPage;
22975 var end = Math.min(start + deltaPerPage, total);
22976 var shownSet = {};
22977 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
22978 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
22979 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
22980 });
22981 var rl = document.getElementById('pg-range-label');
22982 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
22983 var info = document.getElementById('pg-info');
22984 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
22985 var btns = document.getElementById('pg-btns');
22986 if (!btns) return;
22987 btns.innerHTML = '';
22988 if (totalPages <= 1) return;
22989 function makeBtn(lbl, pg, active, disabled) {
22990 var b = document.createElement('button');
22991 b.className = 'pg-btn' + (active ? ' active' : '');
22992 b.textContent = lbl; b.disabled = disabled;
22993 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
22994 return b;
22995 }
22996 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
22997 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
22998 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
22999 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
23000 }
23001
23002 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
23003
23004 function filterRows(status, btn) {
23005 activeStatusFilter = status;
23006 deltaCurrPage = 1;
23007 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
23008 b.classList.remove('active');
23009 });
23010 if (btn) btn.classList.add('active');
23011 renderDeltaPage();
23012 }
23013
23014 // ── Sorting ──────────────────────────────────────────────────────────────
23015 var sortCol = null, sortOrder = 'asc';
23016 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
23017 (function() {
23018 var tbody = document.getElementById('delta-tbody');
23019 if (!tbody) return;
23020 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23021 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
23022 })();
23023
23024 function parseDeltaNum(str) {
23025 if (!str || str === '—') return 0;
23026 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
23027 }
23028
23029 sortHeaders.forEach(function(th) {
23030 th.addEventListener('click', function(e) {
23031 if (e.target.classList.contains('col-resize-handle')) return;
23032 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
23033 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
23034 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23035 th.classList.add('sort-' + sortOrder);
23036 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
23037 var tbody = document.getElementById('delta-tbody');
23038 if (!tbody) return;
23039 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23040 rows.sort(function(a, b) {
23041 var va, vb;
23042 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
23043 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
23044 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
23045 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
23046 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23047 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23048 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23049 else { va = ''; vb = ''; }
23050 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
23051 return va < vb ? 1 : va > vb ? -1 : 0;
23052 });
23053 rows.forEach(function(r) { tbody.appendChild(r); });
23054 deltaCurrPage = 1;
23055 renderDeltaPage();
23056 var activeBtn = document.querySelector('.tab-btn.active');
23057 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23058 if (activeBtn) activeBtn.classList.add('active');
23059 });
23060 });
23061
23062 // ── Column resize ─────────────────────────────────────────────────────────
23063 (function() {
23064 var table = document.getElementById('delta-table');
23065 if (!table) return;
23066 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
23067 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
23068 ths.forEach(function(th, i) {
23069 var handle = th.querySelector('.col-resize-handle');
23070 if (!handle || !cols[i]) return;
23071 var startX, startW;
23072 handle.addEventListener('mousedown', function(e) {
23073 e.stopPropagation(); e.preventDefault();
23074 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
23075 handle.classList.add('dragging');
23076 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
23077 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
23078 document.addEventListener('mousemove', onMove);
23079 document.addEventListener('mouseup', onUp);
23080 });
23081 });
23082 })();
23083
23084 // ── Reset ─────────────────────────────────────────────────────────────────
23085 window.resetDeltaTable = function() {
23086 sortCol = null; sortOrder = 'asc';
23087 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23088 var tbody = document.getElementById('delta-tbody');
23089 if (tbody) {
23090 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23091 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
23092 rows.forEach(function(r) { tbody.appendChild(r); });
23093 }
23094 var table = document.getElementById('delta-table');
23095 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
23096 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
23097 activeStatusFilter = 'all';
23098 deltaCurrPage = 1;
23099 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23100 var allBtn = document.querySelector('.tab-btn');
23101 if (allBtn) allBtn.classList.add('active');
23102 renderDeltaPage();
23103 };
23104
23105 renderDeltaPage();
23106
23107 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
23108 (function() {
23109 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
23110 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
23111 });
23112 var resetBtn = document.getElementById('delta-reset-btn');
23113 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
23114 var csvBtn = document.getElementById('delta-csv-btn');
23115 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
23116 var xlsBtn = document.getElementById('delta-xls-btn');
23117 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
23118 var chartsBtn = document.getElementById('delta-charts-btn');
23119 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
23120 var ppSel = document.getElementById('per-page-sel');
23121 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
23122 var pathLink = document.getElementById('project-path-link');
23123 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
23124 })();
23125
23126 // ── Export helpers ────────────────────────────────────────────────────────
23127 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
23128 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
23129 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);}
23130 function slocMakeXlsx(fname,sd,dr){
23131 var enc=new TextEncoder();
23132 // CRC-32 table
23133 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;}
23134 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;}
23135 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
23136 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
23137 // Shared string table
23138 var ss=[],si={};
23139 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
23140 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23141 // Worksheet builder — each WS() call gets its own row counter R
23142 function WS(){
23143 var R=0,buf=[];
23144 function cl(c){return String.fromCharCode(65+c);}
23145 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
23146 '<v>'+S(v)+'</v></c>';}
23147 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
23148 (st?' s="'+st+'"':'')+'>'+
23149 '<v>'+(+v)+'</v></c>';}
23150 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
23151 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23152 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
23153 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
23154 '<sheetFormatPr defaultRowHeight="15"/>'+
23155 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
23156 return{sc:sc,nc:nc,row:row,xml:xml};
23157 }
23158 // Language breakdown
23159 var lm={};
23160 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;});
23161 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
23162 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
23163 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
23164 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
23165 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23166 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23167 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):'';}
23168 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
23169 // Summary sheet
23170 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
23171 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
23172 r1(s1(0,proj,2));
23173 r1(s1(0,sd.bts+' → '+sd.cts,2));
23174 r1('');
23175 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
23176 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))));
23177 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))));
23178 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))));
23179 r1('');
23180 r1(s1(0,'FILE CHANGES',8));
23181 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
23182 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
23183 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
23184 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
23185 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
23186 if(langs.length){
23187 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
23188 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
23189 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)));});
23190 }
23191 r1('');r1(s1(0,'SCAN METADATA',8));
23192 r1(s1(1,_blabel)+s1(2,_clabel));
23193 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
23194 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
23195 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"/>');
23196 // File Delta sheet
23197 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
23198 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));
23199 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)));});
23200 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
23201 // Shared strings XML
23202 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23203 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
23204 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
23205 // XLSX file map
23206 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
23207 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>',
23208 '_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>',
23209 '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>',
23210 '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>',
23211 '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>',
23212 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
23213 // ZIP packer — STORED (no compression), compatible with all XLSX readers
23214 var zparts=[],zcds=[],zoff=0,znf=0;
23215 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
23216 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
23217 ].forEach(function(name){
23218 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
23219 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]);
23220 var entry=new Uint8Array(lha.length+nb.length+sz);
23221 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
23222 zparts.push(entry);
23223 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));
23224 var cde=new Uint8Array(cda.length+nb.length);
23225 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
23226 zcds.push(cde);zoff+=entry.length;znf++;
23227 });
23228 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
23229 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]);
23230 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
23231 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
23232 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
23233 zout.set(new Uint8Array(ea),zpos);
23234 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
23235 var xurl=URL.createObjectURL(xblob);
23236 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
23237 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
23238 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
23239 }
23240 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;');}
23241 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
23242 function getExportFilename(ext){return _exportBase+'.'+ext;}
23243
23244 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 }}'};
23245 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;}
23246 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
23247 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
23248 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23249 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23250 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):'';}
23251 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
23252 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)]];}
23253 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
23254 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;}
23255 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
23256 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
23257
23258 // ── Chart HTML report ─────────────────────────────────────────────────────
23259 function slocChartReport(fname, sd, dr) {
23260 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
23261 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23262 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23263 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();}
23264 function px(n){return Math.round(n);}
23265 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
23266 // Language map
23267 var lm={};
23268 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;});
23269 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23270
23271 // Builds onmouse* attrs for interactive tooltip on each SVG element
23272 function barTT(label,val){
23273 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
23274 }
23275
23276 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
23277 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'}];
23278 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23279 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
23280 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
23281 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23282 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"/>';}
23283 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23284 c1mets.forEach(function(m,i){
23285 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23286 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23287 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>';
23288 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))+'/>';
23289 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>';
23290 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))+'/>';
23291 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>';
23292 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>';
23293 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>';
23294 });
23295 c1+='</svg>';
23296
23297 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
23298 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'}];
23299 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23300 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
23301 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
23302 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23303 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23304 mets.forEach(function(m,i){
23305 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
23306 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
23307 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
23308 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>';
23309 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
23310 if(bw>=52){
23311 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>';
23312 }else{
23313 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
23314 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>';
23315 }
23316 });
23317 c2+='</svg>';
23318
23319 // ── Chart 3: Language Code Delta ─────────────────────────────────────
23320 var c3='';
23321 if(langs.length){
23322 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23323 var C3W=550,c3LW=124,c3FW=52;
23324 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
23325 var L3rH=30,C3H=langs.length*L3rH+20;
23326 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23327 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23328 langs.forEach(function(l,i){
23329 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
23330 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
23331 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
23332 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23333 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':''))+'/>';
23334 if(bw>=48){
23335 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>';
23336 }else{
23337 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
23338 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>';
23339 }
23340 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>';
23341 });
23342 c3+='</svg>';
23343 }
23344
23345 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
23346 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;});
23347 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23348 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
23349 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23350 var ang=-Math.PI/2;
23351 segs.forEach(function(s){
23352 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23353 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
23354 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
23355 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
23356 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
23357 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)+'%')+'/>';
23358 ang+=sw;
23359 });
23360 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>';
23361 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23362 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>';});
23363 c4+='</svg>';
23364
23365 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
23366 var ttJs='var tt=document.getElementById("ox-tt");'+
23367 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
23368 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
23369 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
23370 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
23371 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
23372 'function oxHT(){tt.style.display="none";}';
23373
23374 // body max-width keeps charts from inflating beyond design dimensions on
23375 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
23376 // each chart's height blows up proportionally, breaking the one-page layout.
23377 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;}'+
23378 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
23379 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
23380 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
23381 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
23382 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
23383 'svg{display:block;}'+
23384 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
23385 '#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;}'+
23386 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
23387 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
23388 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
23389 '<div id="ox-tt"><\/div>'+
23390 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
23391 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
23392 '<div class="two-col">'+
23393 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
23394 '<div class="leg">'+
23395 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
23396 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
23397 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
23398 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
23399 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
23400 '<\/div>'+
23401 '<div class="two-col">'+
23402 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
23403 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
23404 '<\/div>'+
23405 '<script>'+ttJs+'<\/script>'+
23406 '<\/body><\/html>';
23407 slocDownload(html, fname, 'text/html;charset=utf-8;');
23408 }
23409 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
23410 // ── Inline delta charts ────────────────────────────────────────────────────
23411 var _icTT=document.getElementById('ic-tt');
23412 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
23413 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';};
23414 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
23415 (function(){
23416 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
23417 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23418 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();}
23419 function px(n){return Math.round(n);}
23420 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23421 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
23422 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);});}
23423 var dr=getDeltaExportRows(),sd=_sd,lm={};
23424 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;});
23425 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23426 // Chart 1: Baseline vs Current grouped bars
23427 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'}];
23428 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23429 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;
23430 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23431 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"/>';}
23432 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23433 c1mets.forEach(function(m,i){
23434 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23435 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23436 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>';
23437 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"/>';
23438 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>';
23439 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"/>';
23440 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>';
23441 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>';
23442 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>';
23443 });
23444 c1+='</svg>';
23445 // Chart 2: Delta by Metric
23446 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'}];
23447 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23448 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;
23449 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23450 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23451 mets.forEach(function(m,i){
23452 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);
23453 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>';
23454 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
23455 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>';}
23456 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>';}
23457 });
23458 c2+='</svg>';
23459 // Chart 3: Language Code Delta
23460 var c3='';
23461 if(langs.length){
23462 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23463 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;
23464 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23465 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23466 langs.forEach(function(l,i){
23467 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);
23468 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23469 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"/>';
23470 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>';}
23471 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>';}
23472 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>';
23473 });
23474 c3+='</svg>';
23475 }
23476 // Chart 4: File Change Donut
23477 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;});
23478 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23479 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;
23480 if(segs.length===1){
23481 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
23482 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
23483 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
23484 } else {
23485 segs.forEach(function(s){
23486 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23487 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);
23488 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);
23489 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"/>';
23490 ang+=sw;
23491 });
23492 }
23493 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>';
23494 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23495 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>';});
23496 c4+='</svg>';
23497 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
23498 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
23499 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);}
23500 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
23501 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
23502 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent=' /'+el.textContent.replace(/\s+/g,'');});
23503 })();
23504 </script>
23505 <script nonce="{{ csp_nonce }}">
23506 (function(){
23507 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'}];
23508 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);});}
23509 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23510 function init(){
23511 var btn=document.getElementById('settings-btn');if(!btn)return;
23512 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23513 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>';
23514 document.body.appendChild(m);
23515 var g=document.getElementById('scheme-grid');
23516 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);});
23517 var cl=document.getElementById('settings-close');
23518 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);
23519 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');});
23520 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23521 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23522 }
23523 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23524 }());
23525 </script>
23526 <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>
23527</body>
23528</html>
23529"##,
23530 ext = "html"
23531)]
23532#[allow(clippy::struct_excessive_bools)]
23534struct CompareTemplate {
23535 version: &'static str,
23536 project_label: String,
23537 baseline_git_commit: String,
23538 current_git_commit: String,
23539 baseline_run_id: String,
23540 current_run_id: String,
23541 baseline_run_id_short: String,
23542 current_run_id_short: String,
23543 baseline_timestamp: String,
23544 baseline_timestamp_utc_ms: i64,
23545 current_timestamp: String,
23546 current_timestamp_utc_ms: i64,
23547 project_path: String,
23548 baseline_code: u64,
23549 current_code: u64,
23550 code_lines_delta_str: String,
23551 code_lines_delta_class: String,
23552 baseline_files: u64,
23553 current_files: u64,
23554 files_analyzed_delta_str: String,
23555 files_analyzed_delta_class: String,
23556 baseline_comments: u64,
23557 current_comments: u64,
23558 comment_lines_delta_str: String,
23559 comment_lines_delta_class: String,
23560 code_lines_pct_str: String,
23561 files_analyzed_pct_str: String,
23562 comment_lines_pct_str: String,
23563 code_lines_added: i64,
23564 code_lines_removed: i64,
23565 new_scope: bool,
23567 churn_rate_str: String,
23568 churn_rate_class: String,
23569 scope_flag: bool,
23570 files_added: usize,
23571 files_removed: usize,
23572 files_modified: usize,
23573 files_unchanged: usize,
23574 file_rows: Vec<CompareFileDeltaRow>,
23575 baseline_git_author: Option<String>,
23576 current_git_author: Option<String>,
23577 baseline_git_branch: String,
23578 current_git_branch: String,
23579 baseline_git_tags: Option<String>,
23580 current_git_tags: Option<String>,
23581 baseline_git_commit_date: Option<String>,
23582 current_git_commit_date: Option<String>,
23583 project_name: String,
23584 submodule_options: Vec<String>,
23586 has_any_submodule_data: bool,
23588 active_submodule: Option<String>,
23590 super_scope_active: bool,
23592 csp_nonce: String,
23593 coverage_delta_card: String,
23595}
23596
23597#[derive(Template)]
23600#[template(
23601 source = r##"
23602<!doctype html>
23603<html lang="en">
23604<head>
23605 <meta charset="utf-8">
23606 <meta name="viewport" content="width=device-width, initial-scale=1">
23607 <title>OxideSLOC | Sign In</title>
23608 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23609 <style nonce="{{ csp_nonce }}">
23610 :root {
23611 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
23612 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
23613 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
23614 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
23615 }
23616 *{box-sizing:border-box;}
23617 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);}
23618 .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);}
23619 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
23620 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
23621 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
23622 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23623 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23624 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23625 .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;}
23626 @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));}}
23627 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
23628 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
23629 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
23630 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
23631 .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;}
23632 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
23633 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;}
23634 input[type=password]:focus{border-color:var(--oxide);}
23635 .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;}
23636 .btn:hover{opacity:.88;}
23637 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
23638 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
23639 </style>
23640</head>
23641<body>
23642 <div class="background-watermarks" aria-hidden="true">
23643 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23644 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23645 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23646 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23647 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23648 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23649 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23650 </div>
23651 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23652<nav class="top-nav">
23653 <a class="brand" href="/">
23654 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
23655 <span class="brand-title">OxideSLOC</span>
23656 </a>
23657</nav>
23658<main class="page">
23659 <div class="card">
23660 <h1>Sign In</h1>
23661 <p class="subtitle">Enter the API key printed when the server started.</p>
23662 {% if has_error %}
23663 <div class="error">Incorrect API key — please try again.</div>
23664 {% endif %}
23665 <form method="POST" action="/auth/login">
23666 <input type="hidden" name="next" value="{{ next_url|e }}">
23667 <label for="key">API Key</label>
23668 <input id="key" type="password" name="key" autocomplete="current-password"
23669 placeholder="Paste your API key here" autofocus>
23670 <button type="submit" class="btn">Sign In</button>
23671 </form>
23672 <p class="hint">
23673 The API key was printed in the terminal when the server started.<br>
23674 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
23675 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
23676 </p>
23677 </div>
23678</main>
23679<script nonce="{{ csp_nonce }}">
23680(function() {
23681 (function randomizeWatermarks() {
23682 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23683 if (!wms.length) return;
23684 var placed = [];
23685 function tooClose(top, left) {
23686 for (var i = 0; i < placed.length; i++) {
23687 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
23688 if (dt < 16 && dl < 12) return true;
23689 }
23690 return false;
23691 }
23692 function pick(leftBand) {
23693 for (var attempt = 0; attempt < 50; attempt++) {
23694 var top = Math.random() * 88 + 2;
23695 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23696 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
23697 }
23698 var top = Math.random() * 88 + 2;
23699 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23700 placed.push([top, left]); return [top, left];
23701 }
23702 var half = Math.floor(wms.length / 2);
23703 wms.forEach(function (img, i) {
23704 var pos = pick(i < half);
23705 var size = Math.floor(Math.random() * 100 + 120);
23706 var rot = (Math.random() * 360).toFixed(1);
23707 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
23708 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;
23709 });
23710 })();
23711 (function spawnCodeParticles() {
23712 var container = document.getElementById('code-particles');
23713 if (!container) return;
23714 var snippets = [
23715 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
23716 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
23717 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
23718 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
23719 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
23720 ];
23721 var count = 38;
23722 for (var i = 0; i < count; i++) {
23723 (function(idx) {
23724 var el = document.createElement('span');
23725 el.className = 'code-particle';
23726 el.textContent = snippets[idx % snippets.length];
23727 var left = Math.random() * 94 + 2;
23728 var top = Math.random() * 88 + 6;
23729 var dur = (Math.random() * 10 + 9).toFixed(1);
23730 var delay = (Math.random() * 18).toFixed(1);
23731 var rot = (Math.random() * 26 - 13).toFixed(1);
23732 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23733 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
23734 container.appendChild(el);
23735 })(i);
23736 }
23737 })();
23738})();
23739</script>
23740</body>
23741</html>
23742"##,
23743 ext = "html"
23744)]
23745pub(crate) struct LoginTemplate {
23746 pub(crate) csp_nonce: String,
23747 pub(crate) has_error: bool,
23748 pub(crate) next_url: String,
23749 pub(crate) lockout_threshold: u32,
23750}
23751
23752#[derive(Template)]
23755#[template(
23756 source = r##"
23757<!doctype html>
23758<html lang="en">
23759<head>
23760 <meta charset="utf-8">
23761 <meta name="viewport" content="width=device-width, initial-scale=1">
23762 <title>OxideSLOC — REST API Reference</title>
23763 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23764 <style nonce="{{ csp_nonce }}">
23765 :root {
23766 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
23767 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23768 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
23769 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23770 --success:#16a34a;
23771 }
23772 body.dark-theme {
23773 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
23774 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
23775 }
23776 *{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;}
23777 .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);}
23778 .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;}
23779 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
23780 .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));}
23781 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
23782 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
23783 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
23784 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
23785 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23786 @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; } }
23787 .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;}
23788 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
23789 .nav-pill.active{background:rgba(255,255,255,0.22);}
23790 .nav-dropdown{position:relative;display:inline-flex;}
23791 .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;}
23792 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
23793 .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;}
23794 .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;}
23795 .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);}
23796 .nav-dropdown-menu a:last-child{border-bottom:none;}
23797 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
23798 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
23799 .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;}
23800 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23801 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23802 .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;}
23803 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23804 .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);}
23805 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
23806 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
23807 .settings-modal-body{padding:14px 16px 16px;}
23808 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23809 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23810 .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;}
23811 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23812 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23813 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23814 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23815 .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;}
23816 .tz-select:focus{border-color:var(--oxide);}
23817 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
23818 .page-header{margin-bottom:28px;}
23819 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
23820 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
23821 .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;}
23822 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
23823 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
23824 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
23825 .callout strong{font-weight:800;}
23826 .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;}
23827 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
23828 .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;}
23829 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
23830 .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;}
23831 body.dark-theme .base-url-value{color:var(--accent);}
23832 .section{margin-bottom:36px;}
23833 .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);}
23834 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
23835 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
23836 .ep-header:hover{background:var(--surface-2);}
23837 .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;}
23838 .method.get{background:#dcfce7;color:#166534;}
23839 .method.post{background:#dbeafe;color:#1e40af;}
23840 .method.delete{background:#fee2e2;color:#991b1b;}
23841 body.dark-theme .method.get{background:#14532d;color:#86efac;}
23842 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
23843 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
23844 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
23845 .ep-path .param{color:var(--oxide-2);}
23846 body.dark-theme .ep-path .param{color:var(--oxide);}
23847 .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;}
23848 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
23849 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
23850 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
23851 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
23852 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
23853 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
23854 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
23855 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
23856 .ep-card.open .chevron{transform:rotate(180deg);}
23857 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
23858 .ep-card.open .ep-body{display:block;}
23859 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
23860 .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;}
23861 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
23862 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
23863 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
23864 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
23865 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);}
23866 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
23867 table.params tr:last-child td{border-bottom:none;}
23868 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
23869 .pt-type{color:var(--muted-2);font-size:12px;}
23870 .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;}
23871 .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;}
23872 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
23873 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
23874 details.schema{margin-bottom:14px;}
23875 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;}
23876 details.schema summary:hover{color:var(--text);}
23877 .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;}
23878 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
23879 .curl-wrap{position:relative;}
23880 .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;}
23881 .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;}
23882 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
23883 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
23884 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
23885 .webhook-note a{color:var(--accent-2);text-decoration:none;}
23886 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23887 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23888 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23889 .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;}
23890 @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));}}
23891 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23892 .site-footer a{color:var(--muted);}
23893 </style>
23894</head>
23895<body>
23896 <div class="background-watermarks" aria-hidden="true">
23897 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23898 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23899 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23900 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23901 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23902 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23903 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23904 </div>
23905 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23906 <div class="top-nav">
23907 <div class="top-nav-inner">
23908 <a class="brand" href="/">
23909 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23910 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
23911 </a>
23912 <div class="nav-right">
23913 <a class="nav-pill" href="/">Home</a>
23914 <div class="nav-dropdown">
23915 <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>
23916 <div class="nav-dropdown-menu">
23917 <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>
23918 </div>
23919 </div>
23920 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23921 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23922 <div class="nav-dropdown">
23923 <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>
23924 <div class="nav-dropdown-menu">
23925 <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>
23926 </div>
23927 </div>
23928 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23929 <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>
23930 </button>
23931 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23932 <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>
23933 <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>
23934 </button>
23935 </div>
23936 </div>
23937 </div>
23938
23939 <div class="page">
23940 <div class="page-header">
23941 <h1 class="page-title">REST API Reference</h1>
23942 <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>
23943 </div>
23944
23945 {% if has_api_key %}
23946 <div class="callout key-set">
23947 <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>
23948 <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>
23949 </div>
23950 {% else %}
23951 <div class="callout no-key">
23952 <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>
23953 <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>
23954 </div>
23955 {% endif %}
23956
23957 <div class="base-url-bar">
23958 <span class="base-url-label">Base URL</span>
23959 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
23960 </div>
23961
23962 <!-- Health -->
23963 <div class="section">
23964 <h2 class="section-title">Health & Status</h2>
23965 <div class="ep-card">
23966 <div class="ep-header">
23967 <span class="method get">GET</span>
23968 <span class="ep-path">/healthz</span>
23969 <span class="auth-badge public">Public</span>
23970 <span class="ep-desc">Server liveness check</span>
23971 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23972 </div>
23973 <div class="ep-body">
23974 <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>
23975 <p class="params-heading">Response</p>
23976 <div class="schema-block">200 OK
23977Content-Type: text/plain
23978
23979ok</div>
23980 <p class="curl-heading">Example</p>
23981 <div class="curl-wrap">
23982 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
23983 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
23984 </div>
23985 </div>
23986 </div>
23987 </div>
23988
23989 <!-- Badges -->
23990 <div class="section">
23991 <h2 class="section-title">Badges</h2>
23992 <div class="ep-card">
23993 <div class="ep-header">
23994 <span class="method get">GET</span>
23995 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
23996 <span class="auth-badge public">Public</span>
23997 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
23998 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23999 </div>
24000 <div class="ep-body">
24001 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
24002 <p class="params-heading">Path Parameters</p>
24003 <table class="params">
24004 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24005 <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>
24006 </table>
24007 <p class="curl-heading">Example</p>
24008 <div class="curl-wrap">
24009 <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>
24010 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
24011 </div>
24012 </div>
24013 </div>
24014 </div>
24015
24016 <!-- Metrics -->
24017 <div class="section">
24018 <h2 class="section-title">Metrics</h2>
24019
24020 <div class="ep-card">
24021 <div class="ep-header">
24022 <span class="method get">GET</span>
24023 <span class="ep-path">/api/metrics/latest</span>
24024 <span class="auth-badge protected">Protected</span>
24025 <span class="ep-desc">Latest scan metrics (JSON)</span>
24026 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24027 </div>
24028 <div class="ep-body">
24029 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
24030 <details class="schema"><summary>Response schema</summary>
24031<div class="schema-block">{
24032 "run_id": string, // UUID
24033 "timestamp": string, // ISO-8601 UTC
24034 "project": string, // scanned root path
24035 "summary": {
24036 "files_analyzed": number,
24037 "files_skipped": number,
24038 "code_lines": number,
24039 "comment_lines": number,
24040 "blank_lines": number,
24041 "total_physical_lines": number,
24042 "functions": number,
24043 "classes": number,
24044 "variables": number,
24045 "imports": number
24046 },
24047 "languages": [
24048 { "name": string, "files": number, "code_lines": number,
24049 "comment_lines": number, "blank_lines": number,
24050 "functions": number, "classes": number,
24051 "variables": number, "imports": number }
24052 ]
24053}</div></details>
24054 <p class="curl-heading">Example</p>
24055 <div class="curl-wrap">
24056 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24057 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
24058 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
24059 </div>
24060 </div>
24061 </div>
24062
24063 <div class="ep-card">
24064 <div class="ep-header">
24065 <span class="method get">GET</span>
24066 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
24067 <span class="auth-badge protected">Protected</span>
24068 <span class="ep-desc">Metrics for a specific run</span>
24069 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24070 </div>
24071 <div class="ep-body">
24072 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
24073 <p class="params-heading">Path Parameters</p>
24074 <table class="params">
24075 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24076 <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>
24077 </table>
24078 <p class="curl-heading">Example</p>
24079 <div class="curl-wrap">
24080 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24081 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
24082 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
24083 </div>
24084 </div>
24085 </div>
24086
24087 <div class="ep-card">
24088 <div class="ep-header">
24089 <span class="method get">GET</span>
24090 <span class="ep-path">/api/metrics/history</span>
24091 <span class="auth-badge protected">Protected</span>
24092 <span class="ep-desc">Paginated scan history</span>
24093 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24094 </div>
24095 <div class="ep-body">
24096 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
24097 <p class="params-heading">Query Parameters</p>
24098 <table class="params">
24099 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24100 <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>
24101 <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>
24102 </table>
24103 <details class="schema"><summary>Response schema</summary>
24104<div class="schema-block">[{
24105 "run_id": string,
24106 "timestamp": string, // ISO-8601 UTC
24107 "commit": string | null,
24108 "branch": string | null,
24109 "tags": string[],
24110 "code_lines": number,
24111 "comment_lines": number,
24112 "blank_lines": number,
24113 "physical_lines": number,
24114 "files_analyzed": number,
24115 "project_label": string,
24116 "html_url": string | null
24117}]</div></details>
24118 <p class="curl-heading">Example</p>
24119 <div class="curl-wrap">
24120 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24121 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
24122 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
24123 </div>
24124 </div>
24125 </div>
24126
24127 <div class="ep-card">
24128 <div class="ep-header">
24129 <span class="method get">GET</span>
24130 <span class="ep-path">/api/project-history</span>
24131 <span class="auth-badge protected">Protected</span>
24132 <span class="ep-desc">Project-level scan summary</span>
24133 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24134 </div>
24135 <div class="ep-body">
24136 <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>
24137 <p class="params-heading">Query Parameters</p>
24138 <table class="params">
24139 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24140 <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>
24141 </table>
24142 <details class="schema"><summary>Response schema</summary>
24143<div class="schema-block">{
24144 "scan_count": number,
24145 "last_scan_id": string | null,
24146 "last_scan_timestamp": string | null, // ISO-8601
24147 "last_scan_code_lines": number | null,
24148 "last_git_branch": string | null,
24149 "last_git_commit": string | null
24150}</div></details>
24151 <p class="curl-heading">Example</p>
24152 <div class="curl-wrap">
24153 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24154 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
24155 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
24156 </div>
24157 </div>
24158 </div>
24159
24160 <div class="ep-card">
24161 <div class="ep-header">
24162 <span class="method get">GET</span>
24163 <span class="ep-path">/api/metrics/submodules</span>
24164 <span class="auth-badge protected">Protected</span>
24165 <span class="ep-desc">List known git submodules across scans</span>
24166 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24167 </div>
24168 <div class="ep-body">
24169 <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>
24170 <p class="params-heading">Query Parameters</p>
24171 <table class="params">
24172 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24173 <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>
24174 </table>
24175 <details class="schema"><summary>Response schema</summary>
24176<div class="schema-block">[{
24177 "name": string, // submodule name
24178 "relative_path": string // path relative to the project root
24179}]</div></details>
24180 <p class="curl-heading">Example</p>
24181 <div class="curl-wrap">
24182 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24183 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
24184 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
24185 </div>
24186 </div>
24187 </div>
24188 </div>
24189
24190 <!-- Async Run Status -->
24191 <div class="section">
24192 <h2 class="section-title">Async Run Status</h2>
24193
24194 <div class="ep-card">
24195 <div class="ep-header">
24196 <span class="method get">GET</span>
24197 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
24198 <span class="auth-badge protected">Protected</span>
24199 <span class="ep-desc">Poll scan completion</span>
24200 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24201 </div>
24202 <div class="ep-body">
24203 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
24204 <details class="schema"><summary>Response schema</summary>
24205<div class="schema-block">// Running
24206{ "state": "running", "elapsed_secs": number }
24207
24208// Complete
24209{ "state": "complete", "run_id": string }
24210
24211// Failed
24212{ "state": "failed", "message": string }</div></details>
24213 <p class="curl-heading">Example</p>
24214 <div class="curl-wrap">
24215 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24216 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
24217 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
24218 </div>
24219 </div>
24220 </div>
24221
24222 <div class="ep-card">
24223 <div class="ep-header">
24224 <span class="method get">GET</span>
24225 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
24226 <span class="auth-badge protected">Protected</span>
24227 <span class="ep-desc">Poll PDF generation readiness</span>
24228 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24229 </div>
24230 <div class="ep-body">
24231 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
24232 <details class="schema"><summary>Response schema</summary>
24233<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
24234 <p class="curl-heading">Example</p>
24235 <div class="curl-wrap">
24236 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24237 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
24238 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
24239 </div>
24240 </div>
24241 </div>
24242
24243 <div class="ep-card">
24244 <div class="ep-header">
24245 <span class="method post">POST</span>
24246 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
24247 <span class="auth-badge protected">Protected</span>
24248 <span class="ep-desc">Cancel a running scan</span>
24249 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24250 </div>
24251 <div class="ep-body">
24252 <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>
24253 <p class="curl-heading">Example</p>
24254 <div class="curl-wrap">
24255 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
24256 -H "Authorization: Bearer $SLOC_API_KEY" \
24257 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
24258 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
24259 </div>
24260 </div>
24261 </div>
24262 </div>
24263
24264 <!-- Run Management -->
24265 <div class="section">
24266 <h2 class="section-title">Run Management</h2>
24267
24268 <div class="ep-card">
24269 <div class="ep-header">
24270 <span class="method get">GET</span>
24271 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
24272 <span class="auth-badge protected">Protected</span>
24273 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</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 <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>
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">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>
24282 </table>
24283 <details class="schema"><summary>Response</summary>
24284<div class="schema-block">200 OK — Content-Type: application/zip
24285Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
24286
24287404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
24288 <p class="curl-heading">Example</p>
24289 <div class="curl-wrap">
24290 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24291 -o run.zip \
24292 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
24293 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
24294 </div>
24295 </div>
24296 </div>
24297
24298 <div class="ep-card">
24299 <div class="ep-header">
24300 <span class="method delete">DELETE</span>
24301 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
24302 <span class="auth-badge protected">Protected</span>
24303 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
24304 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24305 </div>
24306 <div class="ep-body">
24307 <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>
24308 <p class="params-heading">Path Parameters</p>
24309 <table class="params">
24310 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24311 <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>
24312 </table>
24313 <details class="schema"><summary>Response</summary>
24314<div class="schema-block">204 No Content — run successfully deleted
24315
24316500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
24317 <p class="curl-heading">Example</p>
24318 <div class="curl-wrap">
24319 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
24320 -H "Authorization: Bearer $SLOC_API_KEY" \
24321 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
24322 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
24323 </div>
24324 </div>
24325 </div>
24326
24327 <div class="ep-card">
24328 <div class="ep-header">
24329 <span class="method post">POST</span>
24330 <span class="ep-path">/api/runs/cleanup</span>
24331 <span class="auth-badge protected">Protected</span>
24332 <span class="ep-desc">Bulk delete runs older than N days</span>
24333 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24334 </div>
24335 <div class="ep-body">
24336 <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>
24337 <p class="params-heading">Request Body (application/json)</p>
24338 <table class="params">
24339 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24340 <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>
24341 </table>
24342 <details class="schema"><summary>Response schema</summary>
24343<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
24344 <p class="curl-heading">Example — delete runs older than 60 days</p>
24345 <div class="curl-wrap">
24346 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
24347 -H "Authorization: Bearer $SLOC_API_KEY" \
24348 -H "Content-Type: application/json" \
24349 -d '{"older_than_days":60}' \
24350 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
24351 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
24352 </div>
24353 </div>
24354 </div>
24355 </div>
24356
24357 <!-- Retention Policy -->
24358 <div class="section">
24359 <h2 class="section-title">Retention Policy</h2>
24360
24361 <div class="ep-card">
24362 <div class="ep-header">
24363 <span class="method get">GET</span>
24364 <span class="ep-path">/api/cleanup-policy</span>
24365 <span class="auth-badge protected">Protected</span>
24366 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
24367 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24368 </div>
24369 <div class="ep-body">
24370 <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>
24371 <details class="schema"><summary>Response schema</summary>
24372<div class="schema-block">{
24373 "policy": {
24374 "enabled": boolean,
24375 "max_age_days": number | null, // delete runs older than N days
24376 "max_run_count": number | null, // keep only the N most recent runs
24377 "interval_hours": number // hours between background passes
24378 } | null,
24379 "last_run_at": string | null, // ISO-8601 UTC timestamp
24380 "last_run_deleted": number | null // runs deleted in last pass
24381}</div></details>
24382 <p class="curl-heading">Example</p>
24383 <div class="curl-wrap">
24384 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24385 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24386 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
24387 </div>
24388 </div>
24389 </div>
24390
24391 <div class="ep-card">
24392 <div class="ep-header">
24393 <span class="method post">POST</span>
24394 <span class="ep-path">/api/cleanup-policy</span>
24395 <span class="auth-badge protected">Protected</span>
24396 <span class="ep-desc">Save or update the retention policy</span>
24397 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24398 </div>
24399 <div class="ep-body">
24400 <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>
24401 <p class="params-heading">Request Body (application/json)</p>
24402 <table class="params">
24403 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24404 <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>
24405 <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>
24406 <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>
24407 <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>
24408 </table>
24409 <details class="schema"><summary>Response</summary>
24410<div class="schema-block">204 No Content — policy saved and task (re)started
24411
24412500 Internal Server Error — { "error": string }</div></details>
24413 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
24414 <div class="curl-wrap">
24415 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
24416 -H "Authorization: Bearer $SLOC_API_KEY" \
24417 -H "Content-Type: application/json" \
24418 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
24419 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24420 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
24421 </div>
24422 </div>
24423 </div>
24424
24425 <div class="ep-card">
24426 <div class="ep-header">
24427 <span class="method post">POST</span>
24428 <span class="ep-path">/api/cleanup-policy/run-now</span>
24429 <span class="auth-badge protected">Protected</span>
24430 <span class="ep-desc">Trigger an immediate cleanup pass</span>
24431 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24432 </div>
24433 <div class="ep-body">
24434 <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>
24435 <details class="schema"><summary>Response schema</summary>
24436<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
24437 <p class="curl-heading">Example</p>
24438 <div class="curl-wrap">
24439 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
24440 -H "Authorization: Bearer $SLOC_API_KEY" \
24441 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
24442 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
24443 </div>
24444 </div>
24445 </div>
24446
24447 <div class="ep-card">
24448 <div class="ep-header">
24449 <span class="method delete">DELETE</span>
24450 <span class="ep-path">/api/cleanup-policy</span>
24451 <span class="auth-badge protected">Protected</span>
24452 <span class="ep-desc">Remove the retention policy and stop the background task</span>
24453 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24454 </div>
24455 <div class="ep-body">
24456 <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>
24457 <details class="schema"><summary>Response</summary>
24458<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
24459 <p class="curl-heading">Example</p>
24460 <div class="curl-wrap">
24461 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
24462 -H "Authorization: Bearer $SLOC_API_KEY" \
24463 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24464 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
24465 </div>
24466 </div>
24467 </div>
24468 </div>
24469
24470 <!-- Scan Profiles -->
24471 <div class="section">
24472 <h2 class="section-title">Scan Profiles</h2>
24473
24474 <div class="ep-card">
24475 <div class="ep-header">
24476 <span class="method get">GET</span>
24477 <span class="ep-path">/api/scan-profiles</span>
24478 <span class="auth-badge protected">Protected</span>
24479 <span class="ep-desc">List saved scan profiles</span>
24480 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24481 </div>
24482 <div class="ep-body">
24483 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
24484 <details class="schema"><summary>Response schema</summary>
24485<div class="schema-block">{
24486 "profiles": [{
24487 "id": string, // UUID
24488 "name": string,
24489 "created_at": string, // ISO-8601
24490 "params": object
24491 }]
24492}</div></details>
24493 <p class="curl-heading">Example</p>
24494 <div class="curl-wrap">
24495 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24496 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24497 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
24498 </div>
24499 </div>
24500 </div>
24501
24502 <div class="ep-card">
24503 <div class="ep-header">
24504 <span class="method post">POST</span>
24505 <span class="ep-path">/api/scan-profiles</span>
24506 <span class="auth-badge protected">Protected</span>
24507 <span class="ep-desc">Save a scan profile</span>
24508 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24509 </div>
24510 <div class="ep-body">
24511 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
24512 <p class="params-heading">Request Body (application/json)</p>
24513 <table class="params">
24514 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24515 <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>
24516 <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>
24517 </table>
24518 <details class="schema"><summary>Response schema</summary>
24519<div class="schema-block">{ "ok": true }</div></details>
24520 <p class="curl-heading">Example</p>
24521 <div class="curl-wrap">
24522 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
24523 -H "Authorization: Bearer $SLOC_API_KEY" \
24524 -H "Content-Type: application/json" \
24525 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
24526 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24527 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
24528 </div>
24529 </div>
24530 </div>
24531
24532 <div class="ep-card">
24533 <div class="ep-header">
24534 <span class="method delete">DELETE</span>
24535 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
24536 <span class="auth-badge protected">Protected</span>
24537 <span class="ep-desc">Delete a scan profile</span>
24538 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24539 </div>
24540 <div class="ep-body">
24541 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
24542 <p class="params-heading">Path Parameters</p>
24543 <table class="params">
24544 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24545 <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>
24546 </table>
24547 <details class="schema"><summary>Response schema</summary>
24548<div class="schema-block">{ "ok": true }</div></details>
24549 <p class="curl-heading">Example</p>
24550 <div class="curl-wrap">
24551 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
24552 -H "Authorization: Bearer $SLOC_API_KEY" \
24553 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
24554 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
24555 </div>
24556 </div>
24557 </div>
24558 </div>
24559
24560 <!-- Scheduled Scans -->
24561 <div class="section">
24562 <h2 class="section-title">Scheduled Scans</h2>
24563
24564 <div class="ep-card">
24565 <div class="ep-header">
24566 <span class="method get">GET</span>
24567 <span class="ep-path">/api/schedules</span>
24568 <span class="auth-badge protected">Protected</span>
24569 <span class="ep-desc">List configured schedules</span>
24570 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24571 </div>
24572 <div class="ep-body">
24573 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
24574 <p class="curl-heading">Example</p>
24575 <div class="curl-wrap">
24576 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24577 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24578 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
24579 </div>
24580 </div>
24581 </div>
24582
24583 <div class="ep-card">
24584 <div class="ep-header">
24585 <span class="method post">POST</span>
24586 <span class="ep-path">/api/schedules</span>
24587 <span class="auth-badge protected">Protected</span>
24588 <span class="ep-desc">Create a schedule</span>
24589 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24590 </div>
24591 <div class="ep-body">
24592 <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>
24593 <p class="curl-heading">Example</p>
24594 <div class="curl-wrap">
24595 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
24596 -H "Authorization: Bearer $SLOC_API_KEY" \
24597 -H "Content-Type: application/json" \
24598 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
24599 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24600 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
24601 </div>
24602 </div>
24603 </div>
24604
24605 <div class="ep-card">
24606 <div class="ep-header">
24607 <span class="method delete">DELETE</span>
24608 <span class="ep-path">/api/schedules</span>
24609 <span class="auth-badge protected">Protected</span>
24610 <span class="ep-desc">Delete a schedule</span>
24611 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24612 </div>
24613 <div class="ep-body">
24614 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
24615 <p class="curl-heading">Example</p>
24616 <div class="curl-wrap">
24617 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
24618 -H "Authorization: Bearer $SLOC_API_KEY" \
24619 -H "Content-Type: application/json" \
24620 -d '{"id":"<schedule_id>"}' \
24621 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24622 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
24623 </div>
24624 </div>
24625 </div>
24626 </div>
24627
24628 <!-- Git Browser -->
24629 <div class="section">
24630 <h2 class="section-title">Git Browser</h2>
24631
24632 <div class="ep-card">
24633 <div class="ep-header">
24634 <span class="method get">GET</span>
24635 <span class="ep-path">/api/git/refs</span>
24636 <span class="auth-badge protected">Protected</span>
24637 <span class="ep-desc">List git refs for a repository</span>
24638 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24639 </div>
24640 <div class="ep-body">
24641 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
24642 <p class="params-heading">Query Parameters</p>
24643 <table class="params">
24644 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24645 <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>
24646 </table>
24647 <p class="curl-heading">Example</p>
24648 <div class="curl-wrap">
24649 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24650 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
24651 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
24652 </div>
24653 </div>
24654 </div>
24655
24656 <div class="ep-card">
24657 <div class="ep-header">
24658 <span class="method get">GET</span>
24659 <span class="ep-path">/api/git/scan-ref</span>
24660 <span class="auth-badge protected">Protected</span>
24661 <span class="ep-desc">SLOC-scan a specific git ref</span>
24662 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24663 </div>
24664 <div class="ep-body">
24665 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
24666 <p class="params-heading">Query Parameters</p>
24667 <table class="params">
24668 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24669 <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>
24670 <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>
24671 </table>
24672 <p class="curl-heading">Example</p>
24673 <div class="curl-wrap">
24674 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24675 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
24676 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
24677 </div>
24678 </div>
24679 </div>
24680
24681 <div class="ep-card">
24682 <div class="ep-header">
24683 <span class="method get">GET</span>
24684 <span class="ep-path">/api/git/compare-refs</span>
24685 <span class="auth-badge protected">Protected</span>
24686 <span class="ep-desc">Compare SLOC across two git refs</span>
24687 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24688 </div>
24689 <div class="ep-body">
24690 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
24691 <p class="params-heading">Query Parameters</p>
24692 <table class="params">
24693 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24694 <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>
24695 <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>
24696 <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>
24697 </table>
24698 <p class="curl-heading">Example</p>
24699 <div class="curl-wrap">
24700 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24701 "<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>
24702 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
24703 </div>
24704 </div>
24705 </div>
24706 </div>
24707
24708 <!-- Webhooks -->
24709 <div class="section">
24710 <h2 class="section-title">Webhooks</h2>
24711 <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>
24712
24713 <div class="ep-card">
24714 <div class="ep-header">
24715 <span class="method post">POST</span>
24716 <span class="ep-path">/webhooks/github</span>
24717 <span class="auth-badge hmac">HMAC</span>
24718 <span class="ep-desc">GitHub push event receiver</span>
24719 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24720 </div>
24721 <div class="ep-body">
24722 <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>
24723 <p class="params-heading">Required Headers</p>
24724 <table class="params">
24725 <tr><th>Header</th><th>Value</th></tr>
24726 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
24727 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
24728 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
24729 </table>
24730 </div>
24731 </div>
24732
24733 <div class="ep-card">
24734 <div class="ep-header">
24735 <span class="method post">POST</span>
24736 <span class="ep-path">/webhooks/gitlab</span>
24737 <span class="auth-badge hmac">HMAC</span>
24738 <span class="ep-desc">GitLab push event receiver</span>
24739 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24740 </div>
24741 <div class="ep-body">
24742 <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>
24743 <p class="params-heading">Required Headers</p>
24744 <table class="params">
24745 <tr><th>Header</th><th>Value</th></tr>
24746 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
24747 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
24748 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
24749 </table>
24750 </div>
24751 </div>
24752
24753 <div class="ep-card">
24754 <div class="ep-header">
24755 <span class="method post">POST</span>
24756 <span class="ep-path">/webhooks/bitbucket</span>
24757 <span class="auth-badge hmac">HMAC</span>
24758 <span class="ep-desc">Bitbucket push event receiver</span>
24759 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24760 </div>
24761 <div class="ep-body">
24762 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
24763 <p class="params-heading">Required Headers</p>
24764 <table class="params">
24765 <tr><th>Header</th><th>Value</th></tr>
24766 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
24767 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
24768 </table>
24769 </div>
24770 </div>
24771 </div>
24772
24773 <!-- Config -->
24774 <div class="section">
24775 <h2 class="section-title">Config Import / Export</h2>
24776
24777 <div class="ep-card">
24778 <div class="ep-header">
24779 <span class="method get">GET</span>
24780 <span class="ep-path">/export-config</span>
24781 <span class="auth-badge protected">Protected</span>
24782 <span class="ep-desc">Export server configuration as JSON</span>
24783 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24784 </div>
24785 <div class="ep-body">
24786 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
24787 <p class="curl-heading">Example</p>
24788 <div class="curl-wrap">
24789 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24790 -o config.json \
24791 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
24792 <button class="curl-copy-btn" data-target="c-export">Copy</button>
24793 </div>
24794 </div>
24795 </div>
24796
24797 <div class="ep-card">
24798 <div class="ep-header">
24799 <span class="method post">POST</span>
24800 <span class="ep-path">/import-config</span>
24801 <span class="auth-badge protected">Protected</span>
24802 <span class="ep-desc">Import server configuration</span>
24803 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24804 </div>
24805 <div class="ep-body">
24806 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
24807 <p class="curl-heading">Example</p>
24808 <div class="curl-wrap">
24809 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
24810 -H "Authorization: Bearer $SLOC_API_KEY" \
24811 -H "Content-Type: application/json" \
24812 -d @config.json \
24813 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
24814 <button class="curl-copy-btn" data-target="c-import">Copy</button>
24815 </div>
24816 </div>
24817 </div>
24818 </div>
24819
24820 <!-- CI Ingest -->
24821 <div class="section">
24822 <h2 class="section-title">CI Ingest</h2>
24823
24824 <div class="ep-card">
24825 <div class="ep-header">
24826 <span class="method post">POST</span>
24827 <span class="ep-path">/api/ingest</span>
24828 <span class="auth-badge protected">Protected</span>
24829 <span class="ep-desc">Push a pre-computed scan result from CI</span>
24830 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24831 </div>
24832 <div class="ep-body">
24833 <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>
24834 <p class="params-heading">Query Parameters</p>
24835 <table class="params">
24836 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24837 <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>
24838 </table>
24839 <p class="params-heading">Request Body (application/json)</p>
24840 <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>
24841 <details class="schema"><summary>Response schema</summary>
24842<div class="schema-block">// 201 Created
24843{
24844 "run_id": string, // UUID of the ingested run
24845 "view_url": string // relative URL to the report page
24846}</div></details>
24847 <p class="curl-heading">Example</p>
24848 <div class="curl-wrap">
24849 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
24850 -H "Authorization: Bearer $SLOC_API_KEY" \
24851 -H "Content-Type: application/json" \
24852 -d @result.json \
24853 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
24854 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
24855 </div>
24856 </div>
24857 </div>
24858 </div>
24859
24860 <!-- Artifact Download -->
24861 <div class="section">
24862 <h2 class="section-title">Artifact Download</h2>
24863
24864 <div class="ep-card">
24865 <div class="ep-header">
24866 <span class="method get">GET</span>
24867 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
24868 <span class="auth-badge protected">Protected</span>
24869 <span class="ep-desc">Download or view a scan artifact</span>
24870 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24871 </div>
24872 <div class="ep-body">
24873 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
24874 <p class="params-heading">Path Parameters</p>
24875 <table class="params">
24876 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24877 <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>
24878 <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>
24879 </table>
24880 <p class="params-heading">Query Parameters</p>
24881 <table class="params">
24882 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24883 <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>
24884 </table>
24885 <p class="curl-heading">Example — download JSON result</p>
24886 <div class="curl-wrap">
24887 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24888 -o result.json \
24889 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
24890 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
24891 </div>
24892 </div>
24893 </div>
24894 </div>
24895
24896 <!-- Embed Widget -->
24897 <div class="section">
24898 <h2 class="section-title">Embed Widget</h2>
24899
24900 <div class="ep-card">
24901 <div class="ep-header">
24902 <span class="method get">GET</span>
24903 <span class="ep-path">/embed/summary</span>
24904 <span class="auth-badge protected">Protected</span>
24905 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
24906 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24907 </div>
24908 <div class="ep-body">
24909 <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>
24910 <p class="params-heading">Query Parameters</p>
24911 <table class="params">
24912 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24913 <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>
24914 <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>
24915 </table>
24916 <p class="curl-heading">Example</p>
24917 <div class="curl-wrap">
24918 <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"
24919 width="460" height="260" style="border:none"></iframe></pre>
24920 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
24921 </div>
24922 </div>
24923 </div>
24924 </div>
24925
24926 <!-- Confluence Integration -->
24927 <div class="section">
24928 <h2 class="section-title">Confluence Integration</h2>
24929
24930 <div class="ep-card">
24931 <div class="ep-header">
24932 <span class="method get">GET</span>
24933 <span class="ep-path">/api/confluence/config</span>
24934 <span class="auth-badge protected">Protected</span>
24935 <span class="ep-desc">Get current Confluence configuration</span>
24936 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24937 </div>
24938 <div class="ep-body">
24939 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
24940 <details class="schema"><summary>Response schema</summary>
24941<div class="schema-block">{
24942 "configured": boolean,
24943 "tier": "cloud" | "server",
24944 "base_url": string,
24945 "username": string,
24946 "api_token_set": boolean,
24947 "space_key": string,
24948 "parent_page_id": string | null,
24949 "schedule_auto_post": { "<schedule_id>": boolean }
24950}</div></details>
24951 <p class="curl-heading">Example</p>
24952 <div class="curl-wrap">
24953 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24954 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
24955 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
24956 </div>
24957 </div>
24958 </div>
24959
24960 <div class="ep-card">
24961 <div class="ep-header">
24962 <span class="method post">POST</span>
24963 <span class="ep-path">/api/confluence/config</span>
24964 <span class="auth-badge protected">Protected</span>
24965 <span class="ep-desc">Save Confluence configuration</span>
24966 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24967 </div>
24968 <div class="ep-body">
24969 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
24970 <p class="params-heading">Request Body (application/json)</p>
24971 <table class="params">
24972 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24973 <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>
24974 <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>
24975 <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>
24976 <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>
24977 <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>
24978 <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>
24979 <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>
24980 </table>
24981 <details class="schema"><summary>Response schema</summary>
24982<div class="schema-block">{ "ok": true }</div></details>
24983 <p class="curl-heading">Example</p>
24984 <div class="curl-wrap">
24985 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
24986 -H "Authorization: Bearer $SLOC_API_KEY" \
24987 -H "Content-Type: application/json" \
24988 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
24989 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
24990 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
24991 </div>
24992 </div>
24993 </div>
24994
24995 <div class="ep-card">
24996 <div class="ep-header">
24997 <span class="method post">POST</span>
24998 <span class="ep-path">/api/confluence/test</span>
24999 <span class="auth-badge protected">Protected</span>
25000 <span class="ep-desc">Test Confluence connection</span>
25001 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25002 </div>
25003 <div class="ep-body">
25004 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
25005 <details class="schema"><summary>Response schema</summary>
25006<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
25007 <p class="curl-heading">Example</p>
25008 <div class="curl-wrap">
25009 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
25010 -H "Authorization: Bearer $SLOC_API_KEY" \
25011 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
25012 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
25013 </div>
25014 </div>
25015 </div>
25016
25017 <div class="ep-card">
25018 <div class="ep-header">
25019 <span class="method post">POST</span>
25020 <span class="ep-path">/api/confluence/post</span>
25021 <span class="auth-badge protected">Protected</span>
25022 <span class="ep-desc">Publish a scan report to Confluence</span>
25023 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25024 </div>
25025 <div class="ep-body">
25026 <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>
25027 <p class="params-heading">Request Body (application/json)</p>
25028 <table class="params">
25029 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25030 <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>
25031 <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>
25032 <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>
25033 </table>
25034 <details class="schema"><summary>Response schema</summary>
25035<div class="schema-block">// 200 OK
25036{ "ok": true, "page_id": string }
25037
25038// 400 / 502 on error
25039{ "ok": false, "error": string }</div></details>
25040 <p class="curl-heading">Example</p>
25041 <div class="curl-wrap">
25042 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
25043 -H "Authorization: Bearer $SLOC_API_KEY" \
25044 -H "Content-Type: application/json" \
25045 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
25046 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
25047 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
25048 </div>
25049 </div>
25050 </div>
25051
25052 <div class="ep-card">
25053 <div class="ep-header">
25054 <span class="method get">GET</span>
25055 <span class="ep-path">/api/confluence/wiki-markup</span>
25056 <span class="auth-badge protected">Protected</span>
25057 <span class="ep-desc">Get Confluence wiki markup for a run</span>
25058 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25059 </div>
25060 <div class="ep-body">
25061 <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>
25062 <p class="params-heading">Query Parameters</p>
25063 <table class="params">
25064 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25065 <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>
25066 </table>
25067 <p class="curl-heading">Example</p>
25068 <div class="curl-wrap">
25069 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25070 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
25071 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
25072 </div>
25073 </div>
25074 </div>
25075 </div>
25076
25077 <!-- Authentication -->
25078 <div class="section">
25079 <h2 class="section-title">Authentication</h2>
25080 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
25081
25082 <div class="ep-card">
25083 <div class="ep-header">
25084 <span class="method get">GET</span>
25085 <span class="ep-path">/auth/login</span>
25086 <span class="auth-badge public">Public</span>
25087 <span class="ep-desc">Login page</span>
25088 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25089 </div>
25090 <div class="ep-body">
25091 <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>
25092 <p class="params-heading">Query Parameters</p>
25093 <table class="params">
25094 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25095 <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>
25096 <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>
25097 </table>
25098 </div>
25099 </div>
25100
25101 <div class="ep-card">
25102 <div class="ep-header">
25103 <span class="method post">POST</span>
25104 <span class="ep-path">/auth/login</span>
25105 <span class="auth-badge public">Public</span>
25106 <span class="ep-desc">Submit credentials and get a session cookie</span>
25107 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25108 </div>
25109 <div class="ep-body">
25110 <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>
25111 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
25112 <table class="params">
25113 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25114 <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>
25115 <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>
25116 </table>
25117 <p class="curl-heading">Example</p>
25118 <div class="curl-wrap">
25119 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
25120 -d "key=$SLOC_API_KEY&next=/" \
25121 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
25122 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
25123 </div>
25124 </div>
25125 </div>
25126 </div>
25127
25128 <!-- Coverage Suggestion -->
25129 <div class="section">
25130 <h2 class="section-title">Coverage Suggestion</h2>
25131
25132 <div class="ep-card">
25133 <div class="ep-header">
25134 <span class="method get">GET</span>
25135 <span class="ep-path">/api/suggest-coverage</span>
25136 <span class="auth-badge protected">Protected</span>
25137 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
25138 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25139 </div>
25140 <div class="ep-body">
25141 <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>
25142 <p class="params-heading">Query Parameters</p>
25143 <table class="params">
25144 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25145 <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>
25146 </table>
25147 <details class="schema"><summary>Response schema</summary>
25148<div class="schema-block">{
25149 "found": string | null, // absolute path to the coverage file, if detected
25150 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
25151 "hint": string | null // shell command to generate coverage if not found
25152}</div></details>
25153 <p class="curl-heading">Example</p>
25154 <div class="curl-wrap">
25155 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25156 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
25157 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
25158 </div>
25159 </div>
25160 </div>
25161 </div>
25162
25163 </div>
25164
25165 <footer class="site-footer">
25166 local code analysis - metrics, history and reports
25167 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25168 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25169 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25170 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25171 · <a href="/api-docs" rel="noopener">REST API</a>
25172 </footer>
25173
25174 <script nonce="{{ csp_nonce }}">
25175 (function () {
25176 var base = window.location.origin;
25177 document.getElementById('base-url').textContent = base;
25178 document.querySelectorAll('.base-url-slot').forEach(function (el) {
25179 el.textContent = base;
25180 });
25181
25182 document.querySelectorAll('.ep-header').forEach(function (hdr) {
25183 hdr.addEventListener('click', function () {
25184 hdr.closest('.ep-card').classList.toggle('open');
25185 });
25186 });
25187
25188 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
25189 btn.addEventListener('click', function () {
25190 var targetId = btn.dataset.target;
25191 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
25192 if (!pre) return;
25193 navigator.clipboard.writeText(pre.textContent).then(function () {
25194 btn.textContent = 'Copied!';
25195 btn.classList.add('copied');
25196 setTimeout(function () {
25197 btn.textContent = 'Copy';
25198 btn.classList.remove('copied');
25199 }, 2000);
25200 });
25201 });
25202 });
25203
25204 var storageKey = 'oxide-sloc-theme';
25205 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
25206 var themeBtn = document.getElementById('theme-toggle');
25207 if (themeBtn) {
25208 themeBtn.addEventListener('click', function () {
25209 var dark = document.body.classList.toggle('dark-theme');
25210 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
25211 });
25212 }
25213 (function() {
25214 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'}];
25215 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);});}
25216 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25217 var btn=document.getElementById('settings-btn');if(!btn)return;
25218 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25219 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>';
25220 document.body.appendChild(m);
25221 var g=document.getElementById('scheme-grid');
25222 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);});
25223 var cl=document.getElementById('settings-close');
25224 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);
25225 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');});
25226 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25227 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25228 })();
25229 (function randomizeWatermarks() {
25230 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25231 if (!wms.length) return;
25232 var placed = [];
25233 function tooClose(top, left) {
25234 for (var i = 0; i < placed.length; i++) {
25235 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
25236 if (dt < 16 && dl < 12) return true;
25237 }
25238 return false;
25239 }
25240 function pick(leftBand) {
25241 for (var attempt = 0; attempt < 50; attempt++) {
25242 var top = Math.random() * 88 + 2;
25243 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25244 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25245 }
25246 var top = Math.random() * 88 + 2;
25247 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25248 placed.push([top, left]); return [top, left];
25249 }
25250 var half = Math.floor(wms.length / 2);
25251 wms.forEach(function (img, i) {
25252 var pos = pick(i < half);
25253 var size = Math.floor(Math.random() * 100 + 120);
25254 var rot = (Math.random() * 360).toFixed(1);
25255 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
25256 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;
25257 });
25258 })();
25259 (function spawnCodeParticles() {
25260 var container = document.getElementById('code-particles');
25261 if (!container) return;
25262 var snippets = [
25263 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
25264 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
25265 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
25266 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
25267 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
25268 ];
25269 var count = 38;
25270 for (var i = 0; i < count; i++) {
25271 (function(idx) {
25272 var el = document.createElement('span');
25273 el.className = 'code-particle';
25274 el.textContent = snippets[idx % snippets.length];
25275 var left = Math.random() * 94 + 2;
25276 var top = Math.random() * 88 + 6;
25277 var dur = (Math.random() * 10 + 9).toFixed(1);
25278 var delay = (Math.random() * 18).toFixed(1);
25279 var rot = (Math.random() * 26 - 13).toFixed(1);
25280 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25281 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
25282 container.appendChild(el);
25283 })(i);
25284 }
25285 })();
25286 }());
25287 </script>
25288</body>
25289</html>
25290"##,
25291 ext = "html"
25292)]
25293struct ApiDocsTemplate {
25294 has_api_key: bool,
25295 csp_nonce: String,
25296 version: &'static str,
25297}
25298
25299#[cfg(test)]
25300mod form_config_tests {
25301 use super::*;
25302 use sloc_config::{
25303 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
25304 };
25305
25306 fn blank_form() -> AnalyzeForm {
25307 AnalyzeForm {
25308 path: ".".to_string(),
25309 git_repo: None,
25310 git_ref: None,
25311 mixed_line_policy: None,
25312 python_docstrings_as_comments: None,
25313 generated_file_detection: None,
25314 minified_file_detection: None,
25315 vendor_directory_detection: None,
25316 include_lockfiles: None,
25317 binary_file_behavior: None,
25318 output_dir: None,
25319 report_title: None,
25320 report_header_footer: None,
25321 include_globs: None,
25322 exclude_globs: None,
25323 submodule_breakdown: None,
25324 coverage_file: None,
25325 continuation_line_policy: None,
25326 blank_in_block_comment_policy: None,
25327 count_compiler_directives: None,
25328 style_col_threshold: None,
25329 style_analysis_enabled: None,
25330 style_score_threshold: None,
25331 style_lang_scope: None,
25332 }
25333 }
25334
25335 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
25336 let mut cfg = sloc_config::AppConfig::default();
25337 apply_form_to_config(&mut cfg, form);
25338 cfg
25339 }
25340
25341 #[test]
25344 fn python_docstrings_false_when_unchecked() {
25345 let cfg = apply(&blank_form());
25347 assert!(
25348 !cfg.analysis.python_docstrings_as_comments,
25349 "absent python_docstrings_as_comments must map to false"
25350 );
25351 }
25352
25353 #[test]
25354 fn python_docstrings_true_when_checked() {
25355 let mut form = blank_form();
25357 form.python_docstrings_as_comments = Some("on".to_string());
25358 let cfg = apply(&form);
25359 assert!(cfg.analysis.python_docstrings_as_comments);
25360 }
25361
25362 #[test]
25363 fn python_docstrings_true_for_any_non_none_value() {
25364 let mut form = blank_form();
25366 form.python_docstrings_as_comments = Some("true".to_string());
25367 assert!(apply(&form).analysis.python_docstrings_as_comments);
25368 }
25369
25370 #[test]
25373 fn submodule_breakdown_false_when_unchecked() {
25374 let cfg = apply(&blank_form());
25375 assert!(
25376 !cfg.discovery.submodule_breakdown,
25377 "absent submodule_breakdown must map to false"
25378 );
25379 }
25380
25381 #[test]
25382 fn submodule_breakdown_true_when_value_enabled() {
25383 let mut form = blank_form();
25384 form.submodule_breakdown = Some("enabled".to_string());
25385 assert!(apply(&form).discovery.submodule_breakdown);
25386 }
25387
25388 #[test]
25389 fn submodule_breakdown_false_for_wrong_value() {
25390 let mut form = blank_form();
25392 form.submodule_breakdown = Some("on".to_string());
25393 assert!(
25394 !apply(&form).discovery.submodule_breakdown,
25395 "submodule_breakdown only becomes true for the exact value 'enabled'"
25396 );
25397 }
25398
25399 #[test]
25402 fn generated_detection_true_when_enabled() {
25403 let mut form = blank_form();
25404 form.generated_file_detection = Some("enabled".to_string());
25405 assert!(apply(&form).analysis.generated_file_detection);
25406 }
25407
25408 #[test]
25409 fn generated_detection_false_when_disabled() {
25410 let mut form = blank_form();
25411 form.generated_file_detection = Some("disabled".to_string());
25412 assert!(!apply(&form).analysis.generated_file_detection);
25413 }
25414
25415 #[test]
25416 fn generated_detection_true_when_absent() {
25417 assert!(
25419 apply(&blank_form()).analysis.generated_file_detection,
25420 "absent field must default to true (detection on)"
25421 );
25422 }
25423
25424 #[test]
25427 fn minified_detection_false_when_disabled() {
25428 let mut form = blank_form();
25429 form.minified_file_detection = Some("disabled".to_string());
25430 assert!(!apply(&form).analysis.minified_file_detection);
25431 }
25432
25433 #[test]
25434 fn minified_detection_true_when_enabled() {
25435 let mut form = blank_form();
25436 form.minified_file_detection = Some("enabled".to_string());
25437 assert!(apply(&form).analysis.minified_file_detection);
25438 }
25439
25440 #[test]
25441 fn minified_detection_true_when_absent() {
25442 assert!(apply(&blank_form()).analysis.minified_file_detection);
25443 }
25444
25445 #[test]
25448 fn vendor_detection_false_when_disabled() {
25449 let mut form = blank_form();
25450 form.vendor_directory_detection = Some("disabled".to_string());
25451 assert!(!apply(&form).analysis.vendor_directory_detection);
25452 }
25453
25454 #[test]
25455 fn vendor_detection_true_when_enabled() {
25456 let mut form = blank_form();
25457 form.vendor_directory_detection = Some("enabled".to_string());
25458 assert!(apply(&form).analysis.vendor_directory_detection);
25459 }
25460
25461 #[test]
25462 fn vendor_detection_true_when_absent() {
25463 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
25464 }
25465
25466 #[test]
25469 fn lockfiles_false_when_absent() {
25470 assert!(!apply(&blank_form()).analysis.include_lockfiles);
25472 }
25473
25474 #[test]
25475 fn lockfiles_false_when_disabled() {
25476 let mut form = blank_form();
25477 form.include_lockfiles = Some("disabled".to_string());
25478 assert!(!apply(&form).analysis.include_lockfiles);
25479 }
25480
25481 #[test]
25482 fn lockfiles_true_when_enabled() {
25483 let mut form = blank_form();
25484 form.include_lockfiles = Some("enabled".to_string());
25485 assert!(apply(&form).analysis.include_lockfiles);
25486 }
25487
25488 #[test]
25491 fn compiler_directives_true_when_absent() {
25492 assert!(
25493 apply(&blank_form()).analysis.count_compiler_directives,
25494 "absent count_compiler_directives must default to true"
25495 );
25496 }
25497
25498 #[test]
25499 fn compiler_directives_true_when_enabled() {
25500 let mut form = blank_form();
25501 form.count_compiler_directives = Some("enabled".to_string());
25502 assert!(apply(&form).analysis.count_compiler_directives);
25503 }
25504
25505 #[test]
25506 fn compiler_directives_false_when_disabled() {
25507 let mut form = blank_form();
25508 form.count_compiler_directives = Some("disabled".to_string());
25509 assert!(!apply(&form).analysis.count_compiler_directives);
25510 }
25511
25512 #[test]
25515 fn mixed_policy_unchanged_when_absent() {
25516 assert_eq!(
25518 apply(&blank_form()).analysis.mixed_line_policy,
25519 MixedLinePolicy::CodeOnly
25520 );
25521 }
25522
25523 #[test]
25524 fn mixed_policy_code_only() {
25525 let mut form = blank_form();
25526 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
25527 assert_eq!(
25528 apply(&form).analysis.mixed_line_policy,
25529 MixedLinePolicy::CodeOnly
25530 );
25531 }
25532
25533 #[test]
25534 fn mixed_policy_code_and_comment() {
25535 let mut form = blank_form();
25536 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
25537 assert_eq!(
25538 apply(&form).analysis.mixed_line_policy,
25539 MixedLinePolicy::CodeAndComment
25540 );
25541 }
25542
25543 #[test]
25544 fn mixed_policy_comment_only() {
25545 let mut form = blank_form();
25546 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
25547 assert_eq!(
25548 apply(&form).analysis.mixed_line_policy,
25549 MixedLinePolicy::CommentOnly
25550 );
25551 }
25552
25553 #[test]
25554 fn mixed_policy_separate_mixed_category() {
25555 let mut form = blank_form();
25556 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
25557 assert_eq!(
25558 apply(&form).analysis.mixed_line_policy,
25559 MixedLinePolicy::SeparateMixedCategory
25560 );
25561 }
25562
25563 #[test]
25566 fn binary_behavior_skip_when_absent() {
25567 assert_eq!(
25568 apply(&blank_form()).analysis.binary_file_behavior,
25569 BinaryFileBehavior::Skip
25570 );
25571 }
25572
25573 #[test]
25574 fn binary_behavior_skip() {
25575 let mut form = blank_form();
25576 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
25577 assert_eq!(
25578 apply(&form).analysis.binary_file_behavior,
25579 BinaryFileBehavior::Skip
25580 );
25581 }
25582
25583 #[test]
25584 fn binary_behavior_fail() {
25585 let mut form = blank_form();
25586 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
25587 assert_eq!(
25588 apply(&form).analysis.binary_file_behavior,
25589 BinaryFileBehavior::Fail
25590 );
25591 }
25592
25593 #[test]
25596 fn continuation_policy_each_physical_when_absent() {
25597 assert_eq!(
25598 apply(&blank_form()).analysis.continuation_line_policy,
25599 ContinuationLinePolicy::EachPhysicalLine
25600 );
25601 }
25602
25603 #[test]
25604 fn continuation_policy_collapse_to_logical() {
25605 let mut form = blank_form();
25606 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
25607 assert_eq!(
25608 apply(&form).analysis.continuation_line_policy,
25609 ContinuationLinePolicy::CollapseToLogical
25610 );
25611 }
25612
25613 #[test]
25616 fn blank_in_block_comment_count_as_comment_when_absent() {
25617 assert_eq!(
25618 apply(&blank_form()).analysis.blank_in_block_comment_policy,
25619 BlankInBlockCommentPolicy::CountAsComment
25620 );
25621 }
25622
25623 #[test]
25624 fn blank_in_block_comment_count_as_blank() {
25625 let mut form = blank_form();
25626 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
25627 assert_eq!(
25628 apply(&form).analysis.blank_in_block_comment_policy,
25629 BlankInBlockCommentPolicy::CountAsBlank
25630 );
25631 }
25632
25633 #[test]
25636 fn style_threshold_80() {
25637 let mut form = blank_form();
25638 form.style_col_threshold = Some("80".to_string());
25639 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
25640 }
25641
25642 #[test]
25643 fn style_threshold_100() {
25644 let mut form = blank_form();
25645 form.style_col_threshold = Some("100".to_string());
25646 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
25647 }
25648
25649 #[test]
25650 fn style_threshold_120() {
25651 let mut form = blank_form();
25652 form.style_col_threshold = Some("120".to_string());
25653 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
25654 }
25655
25656 #[test]
25657 fn style_threshold_invalid_value_leaves_default() {
25658 let mut cfg = sloc_config::AppConfig::default();
25660 let mut form = blank_form();
25661 form.style_col_threshold = Some("42".to_string());
25662 apply_form_to_config(&mut cfg, &form);
25663 assert_eq!(
25664 cfg.analysis.style_col_threshold, 80,
25665 "invalid threshold must not change config"
25666 );
25667 }
25668
25669 #[test]
25670 fn style_threshold_non_numeric_leaves_default() {
25671 let mut cfg = sloc_config::AppConfig::default();
25672 let mut form = blank_form();
25673 form.style_col_threshold = Some("large".to_string());
25674 apply_form_to_config(&mut cfg, &form);
25675 assert_eq!(cfg.analysis.style_col_threshold, 80);
25676 }
25677
25678 #[test]
25679 fn style_threshold_zero_leaves_default() {
25680 let mut cfg = sloc_config::AppConfig::default();
25681 let mut form = blank_form();
25682 form.style_col_threshold = Some("0".to_string());
25683 apply_form_to_config(&mut cfg, &form);
25684 assert_eq!(cfg.analysis.style_col_threshold, 80);
25685 }
25686
25687 #[test]
25688 fn style_threshold_absent_leaves_default() {
25689 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
25690 }
25691
25692 #[test]
25695 fn coverage_file_none_when_absent() {
25696 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
25697 }
25698
25699 #[test]
25700 fn coverage_file_none_when_whitespace_only() {
25701 let mut form = blank_form();
25702 form.coverage_file = Some(" ".to_string());
25703 assert!(
25704 apply(&form).analysis.coverage_file.is_none(),
25705 "whitespace-only coverage_file must be treated as None"
25706 );
25707 }
25708
25709 #[test]
25710 fn coverage_file_set_when_non_empty() {
25711 let mut form = blank_form();
25712 form.coverage_file = Some("coverage/lcov.info".to_string());
25713 assert_eq!(
25714 apply(&form).analysis.coverage_file,
25715 Some(std::path::PathBuf::from("coverage/lcov.info"))
25716 );
25717 }
25718
25719 #[test]
25720 fn coverage_file_trims_whitespace() {
25721 let mut form = blank_form();
25722 form.coverage_file = Some(" coverage/lcov.info ".to_string());
25723 assert_eq!(
25724 apply(&form).analysis.coverage_file,
25725 Some(std::path::PathBuf::from("coverage/lcov.info"))
25726 );
25727 }
25728
25729 #[test]
25732 fn report_title_unchanged_when_absent() {
25733 let original = sloc_config::AppConfig::default()
25734 .reporting
25735 .report_title
25736 .clone();
25737 assert_eq!(apply(&blank_form()).reporting.report_title, original);
25738 }
25739
25740 #[test]
25741 fn report_title_unchanged_when_whitespace_only() {
25742 let original = sloc_config::AppConfig::default()
25743 .reporting
25744 .report_title
25745 .clone();
25746 let mut form = blank_form();
25747 form.report_title = Some(" ".to_string());
25748 assert_eq!(
25749 apply(&form).reporting.report_title,
25750 original,
25751 "whitespace-only title must not overwrite the default"
25752 );
25753 }
25754
25755 #[test]
25756 fn report_title_updated_and_trimmed() {
25757 let mut form = blank_form();
25758 form.report_title = Some(" My Project ".to_string());
25759 assert_eq!(apply(&form).reporting.report_title, "My Project");
25760 }
25761
25762 #[test]
25765 fn header_footer_none_when_absent() {
25766 assert!(apply(&blank_form())
25767 .reporting
25768 .report_header_footer
25769 .is_none());
25770 }
25771
25772 #[test]
25773 fn header_footer_none_when_whitespace_only() {
25774 let mut form = blank_form();
25775 form.report_header_footer = Some(" ".to_string());
25776 assert!(apply(&form).reporting.report_header_footer.is_none());
25777 }
25778
25779 #[test]
25780 fn header_footer_set_and_trimmed() {
25781 let mut form = blank_form();
25782 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
25783 assert_eq!(
25784 apply(&form).reporting.report_header_footer,
25785 Some("Confidential — Internal Use".to_string())
25786 );
25787 }
25788
25789 #[test]
25792 fn include_globs_empty_when_absent() {
25793 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
25794 }
25795
25796 #[test]
25797 fn include_globs_newline_separated() {
25798 let mut form = blank_form();
25799 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
25800 assert_eq!(
25801 apply(&form).discovery.include_globs,
25802 vec!["src/**/*.rs", "tests/**/*.rs"]
25803 );
25804 }
25805
25806 #[test]
25807 fn exclude_globs_comma_separated() {
25808 let mut form = blank_form();
25809 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
25810 assert_eq!(
25811 apply(&form).discovery.exclude_globs,
25812 vec!["vendor/**", "node_modules/**"]
25813 );
25814 }
25815
25816 #[test]
25817 fn globs_mixed_separators() {
25818 let mut form = blank_form();
25819 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
25820 assert_eq!(
25821 apply(&form).discovery.exclude_globs,
25822 vec!["a/**", "b/**", "c/**"]
25823 );
25824 }
25825
25826 #[test]
25829 fn split_patterns_none_is_empty() {
25830 assert!(split_patterns(None).is_empty());
25831 }
25832
25833 #[test]
25834 fn split_patterns_empty_string_is_empty() {
25835 assert!(split_patterns(Some("")).is_empty());
25836 }
25837
25838 #[test]
25839 fn split_patterns_whitespace_only_is_empty() {
25840 assert!(split_patterns(Some(" \n \n ")).is_empty());
25841 }
25842
25843 #[test]
25844 fn split_patterns_newlines() {
25845 assert_eq!(
25846 split_patterns(Some("a/**\nb/**\nc/**")),
25847 vec!["a/**", "b/**", "c/**"]
25848 );
25849 }
25850
25851 #[test]
25852 fn split_patterns_commas() {
25853 assert_eq!(
25854 split_patterns(Some("a/**,b/**,c/**")),
25855 vec!["a/**", "b/**", "c/**"]
25856 );
25857 }
25858
25859 #[test]
25860 fn split_patterns_mixed() {
25861 assert_eq!(
25862 split_patterns(Some("a/**\nb/**,c/**")),
25863 vec!["a/**", "b/**", "c/**"]
25864 );
25865 }
25866
25867 #[test]
25868 fn split_patterns_trims_whitespace() {
25869 assert_eq!(
25870 split_patterns(Some(" a/** \n b/** ")),
25871 vec!["a/**", "b/**"]
25872 );
25873 }
25874
25875 #[test]
25876 fn split_patterns_filters_empty_entries() {
25877 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
25878 }
25879
25880 #[test]
25881 fn split_patterns_single_entry() {
25882 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
25883 }
25884}