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, FileChangeStatus, RegistryEntry, ScanRegistry,
72 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
73};
74use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html, write_pdf_from_run};
75const MAX_CONCURRENT_ANALYSES: usize = 4;
76
77#[cfg(all(target_os = "windows", feature = "native-dialog"))]
85#[allow(clippy::upper_case_acronyms)]
86mod win_dialog_focus {
87 use std::mem::size_of;
88
89 type HWND = *mut core::ffi::c_void;
90 type DWORD = u32;
91 type UINT = u32;
92 type BOOL = i32;
93
94 #[repr(C)]
98 #[allow(non_snake_case)]
99 struct FLASHWINFO {
100 cbSize: UINT,
101 hwnd: HWND,
102 dwFlags: DWORD,
103 uCount: UINT,
104 dwTimeout: DWORD,
105 }
106
107 const FLASHW_ALL: DWORD = 0x3;
108 const FLASHW_TIMERNOFG: DWORD = 0xC;
109
110 #[link(name = "user32")]
111 extern "system" {
112 fn GetForegroundWindow() -> HWND;
113 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
114 fn BringWindowToTop(hWnd: HWND) -> BOOL;
115 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
116 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
117 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
118 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
119 }
120
121 #[link(name = "kernel32")]
122 extern "system" {
123 fn GetCurrentThreadId() -> DWORD;
124 }
125
126 pub fn attach_to_foreground() -> DWORD {
131 unsafe {
132 let fg_hwnd = GetForegroundWindow();
133 if fg_hwnd.is_null() {
134 return 0;
135 }
136 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
137 let my_tid = GetCurrentThreadId();
138 if fg_tid == my_tid {
139 return 0;
140 }
141 AttachThreadInput(my_tid, fg_tid, 1);
142 fg_tid
143 }
144 }
145
146 pub fn detach_from_foreground(fg_tid: DWORD) {
148 if fg_tid == 0 {
149 return;
150 }
151 unsafe {
152 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
153 }
154 }
155
156 pub fn flash_dialog_when_ready(title: String) {
160 std::thread::spawn(move || {
161 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
162 for _ in 0..40 {
163 std::thread::sleep(std::time::Duration::from_millis(80));
164 unsafe {
165 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
166 if !hwnd.is_null() {
167 SetForegroundWindow(hwnd);
168 BringWindowToTop(hwnd);
169 #[allow(non_snake_case)]
170 FlashWindowEx(&FLASHWINFO {
171 #[allow(clippy::cast_possible_truncation)]
174 cbSize: size_of::<FLASHWINFO>() as UINT,
175 hwnd,
176 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
177 uCount: 3,
178 dwTimeout: 0,
179 });
180 break;
181 }
182 }
183 }
184 });
185 }
186}
187
188pub(crate) struct IpRateLimiter {
191 window: Duration,
192 max_requests: usize,
193 pub(crate) auth_lockout_threshold: u32,
194 auth_lockout_window: Duration,
195 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
196 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
197}
198
199impl IpRateLimiter {
200 pub(crate) fn new(
201 window: Duration,
202 max_requests: usize,
203 auth_lockout_threshold: u32,
204 auth_lockout_window: Duration,
205 ) -> Self {
206 Self {
207 window,
208 max_requests,
209 auth_lockout_threshold,
210 auth_lockout_window,
211 state: std::sync::Mutex::new(HashMap::new()),
212 auth_failures: std::sync::Mutex::new(HashMap::new()),
213 }
214 }
215
216 #[allow(clippy::significant_drop_tightening)]
219 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
220 let now = Instant::now();
221 let cutoff = now.checked_sub(self.window).unwrap_or(now);
222 let mut state = self
223 .state
224 .lock()
225 .unwrap_or_else(std::sync::PoisonError::into_inner);
226 if state.len() > 10_000 {
227 state.retain(|_, bucket| {
228 while bucket.front().is_some_and(|t| *t <= cutoff) {
229 bucket.pop_front();
230 }
231 !bucket.is_empty()
232 });
233 }
234 let bucket = state.entry(ip).or_default();
235 while bucket.front().is_some_and(|t| *t <= cutoff) {
236 bucket.pop_front();
237 }
238 if bucket.len() >= self.max_requests {
239 false
240 } else {
241 bucket.push_back(now);
242 true
243 }
244 }
245
246 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
247 let now = Instant::now();
248 let mut map = self
249 .auth_failures
250 .lock()
251 .unwrap_or_else(std::sync::PoisonError::into_inner);
252 map.entry(ip)
253 .and_modify(|e| {
254 e.0 += 1;
255 e.1 = now;
256 })
257 .or_insert_with(|| (1, now));
258 }
259
260 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
261 let mut map = self
262 .auth_failures
263 .lock()
264 .unwrap_or_else(std::sync::PoisonError::into_inner);
265 let expired = map
266 .get(&ip)
267 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
268 if expired {
269 map.remove(&ip);
270 return false;
271 }
272 map.get(&ip)
273 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
274 }
275
276 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
277 let map = self
278 .auth_failures
279 .lock()
280 .unwrap_or_else(std::sync::PoisonError::into_inner);
281 map.get(&ip).map_or(0, |e| {
282 self.auth_lockout_window
283 .checked_sub(e.1.elapsed())
284 .map_or(0, |r| r.as_secs())
285 })
286 }
287
288 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
289 tokio::spawn(async move {
290 let mut interval = tokio::time::interval(Duration::from_mins(1));
291 interval.tick().await; loop {
293 interval.tick().await;
294 let now = Instant::now();
295 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
296 {
297 let mut state = limiter
298 .state
299 .lock()
300 .unwrap_or_else(std::sync::PoisonError::into_inner);
301 state.retain(|_, bucket| {
302 while bucket.front().is_some_and(|t| *t <= cutoff) {
303 bucket.pop_front();
304 }
305 !bucket.is_empty()
306 });
307 }
308 {
309 let mut auth = limiter
310 .auth_failures
311 .lock()
312 .unwrap_or_else(std::sync::PoisonError::into_inner);
313 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
314 }
315 }
316 });
317 }
318}
319
320fn spawn_upload_staging_cleanup() {
324 tokio::spawn(async move {
325 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
326 .ok()
327 .and_then(|v| v.parse().ok())
328 .unwrap_or(4);
329 let ttl_secs = ttl_hours * 3600;
330 let mut interval = tokio::time::interval(Duration::from_hours(1));
331 interval.tick().await; loop {
333 interval.tick().await;
334 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
335 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
336 continue;
337 };
338 while let Ok(Some(entry)) = dir.next_entry().await {
339 let path = entry.path();
340 let age_secs = tokio::fs::metadata(&path)
341 .await
342 .ok()
343 .and_then(|m| m.modified().ok())
344 .and_then(|t| t.elapsed().ok())
345 .map_or(0, |d| d.as_secs());
346 if age_secs > ttl_secs {
347 tracing::debug!(
348 event = "upload_staging_cleanup",
349 path = %path.display(),
350 age_secs,
351 "removing stale upload staging directory"
352 );
353 let _ = tokio::fs::remove_dir_all(&path).await;
354 }
355 }
356 }
357 });
358}
359
360#[derive(Clone, Debug, Default)]
362struct RunResultContext {
363 prev_entry: Option<RegistryEntry>,
364 prev_scan_count: usize,
365 project_path: String,
366}
367
368#[derive(Clone)]
370enum AsyncRunState {
371 Running {
372 started_at: std::time::Instant,
373 cancel_token: Arc<std::sync::atomic::AtomicBool>,
374 phase: Arc<std::sync::Mutex<String>>,
375 files_done: Arc<std::sync::atomic::AtomicUsize>,
376 files_total: Arc<std::sync::atomic::AtomicUsize>,
377 },
378 Complete {
380 run_id: String,
381 },
382 Failed {
383 message: String,
384 },
385 Cancelled,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
391struct ScanProfile {
392 id: String,
393 name: String,
394 created_at: String,
395 params: serde_json::Value,
397}
398
399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
400struct ScanProfileStore {
401 profiles: Vec<ScanProfile>,
402}
403
404impl ScanProfileStore {
405 fn load(path: &std::path::Path) -> Self {
406 fs::read_to_string(path)
407 .ok()
408 .and_then(|s| serde_json::from_str(&s).ok())
409 .unwrap_or_default()
410 }
411
412 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
413 if let Some(parent) = path.parent() {
414 fs::create_dir_all(parent)?;
415 }
416 let json = serde_json::to_string_pretty(self)?;
417 fs::write(path, json)?;
418 Ok(())
419 }
420}
421
422#[derive(Clone)]
423pub(crate) struct AppState {
424 pub(crate) base_config: AppConfig,
425 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
426 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
427 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
428 pub(crate) registry_path: PathBuf,
429 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
430 pub(crate) server_mode: bool,
431 pub(crate) tls_enabled: bool,
432 pub(crate) api_keys: Vec<secrecy::Secret<String>>,
433 pub(crate) rate_limiter: Arc<IpRateLimiter>,
434 pub(crate) trust_proxy: bool,
435 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
438 pub(crate) git_clones_dir: PathBuf,
440 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
442 pub(crate) schedules_path: PathBuf,
443 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
445 pub(crate) scan_profiles_path: PathBuf,
446 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
447 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
449 pub(crate) confluence_path: PathBuf,
450 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
452 pub(crate) watched_dirs_path: PathBuf,
453}
454
455type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
456
457#[derive(Clone, Debug)]
460pub(crate) struct RunArtifacts {
461 output_dir: PathBuf,
462 html_path: Option<PathBuf>,
463 pdf_path: Option<PathBuf>,
464 json_path: Option<PathBuf>,
465 csv_path: Option<PathBuf>,
466 xlsx_path: Option<PathBuf>,
467 scan_config_path: Option<PathBuf>,
468 report_title: String,
469 result_context: RunResultContext,
470}
471
472#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
474 let protected = Router::new()
475 .route("/", get(splash))
476 .route("/scan-setup", get(scan_setup_handler))
477 .route("/scan", get(index))
478 .route("/analyze", post(analyze_handler))
479 .route("/preview", get(preview_handler))
480 .route("/api/suggest-coverage", get(api_suggest_coverage))
481 .route("/pick-directory", get(pick_directory_handler))
482 .route("/open-path", get(open_path_handler))
483 .route("/pick-file", get(pick_file_handler))
484 .route(
485 "/api/upload-directory",
486 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
487 )
488 .route(
489 "/api/upload-file",
490 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
491 )
492 .route(
493 "/api/upload-tarball",
494 post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
495 )
496 .route("/locate-report", post(locate_report_handler))
497 .route("/locate-reports-dir", post(locate_reports_dir_handler))
498 .route("/relocate-scan", post(relocate_scan_handler))
499 .route("/watched-dirs/add", post(add_watched_dir_handler))
500 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
501 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
502 .route("/view-reports", get(history_handler))
503 .route("/compare-scans", get(compare_select_handler))
504 .route("/compare", get(compare_handler))
505 .route("/images/{folder}/{file}", get(image_handler))
506 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
507 .route("/api/metrics/latest", get(api_metrics_latest_handler))
508 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
509 .route("/api/metrics/history", get(api_metrics_history_handler))
510 .route(
511 "/api/metrics/submodules",
512 get(api_metrics_submodules_handler),
513 )
514 .route("/api/ingest", post(api_ingest_handler))
515 .route("/api/project-history", get(project_history_handler))
516 .route("/trend-reports", get(trend_report_handler))
517 .route("/test-metrics", get(test_metrics_handler))
518 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
519 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
520 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
521 .route("/runs/result/{run_id}", get(async_run_result_handler))
522 .route("/embed/summary", get(embed_handler))
523 .route("/git-browser", get(git_browser::git_browser_handler))
525 .route("/api/git/refs", get(git_browser::api_list_refs))
526 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
527 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
528 .route("/export-config", get(export_config_handler))
530 .route("/import-config", post(import_config_handler))
531 .route("/api/scan-profiles", get(api_list_scan_profiles))
533 .route("/api/scan-profiles", post(api_save_scan_profile))
534 .route(
535 "/api/scan-profiles/{id}",
536 axum::routing::delete(api_delete_scan_profile),
537 )
538 .route("/integrations", get(integrations::integrations_handler))
540 .route(
541 "/webhook-setup",
542 get(|| async { axum::response::Redirect::permanent("/integrations") }),
543 )
544 .route(
545 "/confluence-setup",
546 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
547 )
548 .route("/api/schedules", get(git_webhook::api_list_schedules))
549 .route("/api/schedules", post(git_webhook::api_create_schedule))
550 .route(
551 "/api/schedules",
552 axum::routing::delete(git_webhook::api_delete_schedule),
553 )
554 .route(
555 "/api/confluence/config",
556 get(confluence::api_get_confluence_config),
557 )
558 .route(
559 "/api/confluence/config",
560 post(confluence::api_save_confluence_config),
561 )
562 .route(
563 "/api/confluence/test",
564 post(confluence::api_test_confluence),
565 )
566 .route(
567 "/api/confluence/post",
568 post(confluence::api_post_to_confluence),
569 )
570 .route(
571 "/api/confluence/wiki-markup",
572 get(confluence::api_wiki_markup),
573 )
574 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
576 .route(
577 "/api/runs/{run_id}",
578 axum::routing::delete(delete_run_handler),
579 )
580 .route("/api/runs/cleanup", post(cleanup_runs_handler))
581 .route("/api-docs", get(api_docs_handler))
583 .route_layer(middleware::from_fn_with_state(
584 state.clone(),
585 auth::require_api_key,
586 ));
587
588 protected
589 .route("/healthz", get(healthz))
590 .route("/api/health", get(healthz))
591 .route("/metrics", get(metrics_handler))
592 .route("/api/version", get(api_version_handler))
593 .route("/api/openapi.yaml", get(openapi_yaml_handler))
594 .route("/badge/{metric}", get(badge_handler))
595 .route("/static/chart.js", get(chart_js_handler))
596 .route("/static/chart-report.js", get(report_chart_js_handler))
597 .route("/auth/login", get(auth::auth_login_get))
598 .route("/auth/login", post(auth::auth_login_post))
599 .route(
602 "/webhooks/github",
603 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
604 )
605 .route(
606 "/webhooks/gitlab",
607 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
608 )
609 .route(
610 "/webhooks/bitbucket",
611 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
612 )
613 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
614 .layer(middleware::from_fn_with_state(
615 state.clone(),
616 add_security_headers,
617 ))
618 .layer(build_cors_layer(state.server_mode))
619 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
620 .with_state(state)
621}
622
623pub fn make_test_router() -> Router {
625 let tmp = std::env::temp_dir().join("sloc_test");
626 let state = AppState {
627 base_config: AppConfig::default(),
628 artifacts: Arc::new(Mutex::new(HashMap::new())),
629 async_runs: Arc::new(Mutex::new(HashMap::new())),
630 registry: Arc::new(Mutex::new(ScanRegistry::default())),
631 registry_path: tmp.join("registry.json"),
632 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
633 server_mode: false,
634 tls_enabled: false,
635 api_keys: vec![],
636 rate_limiter: Arc::new(IpRateLimiter::new(
637 Duration::from_mins(1),
638 600,
639 10,
640 Duration::from_hours(1),
641 )),
642 trust_proxy: false,
643 trusted_proxy_ips: vec![],
644 git_clones_dir: tmp.join("git-clones"),
645 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
646 schedules_path: tmp.join("schedules.json"),
647 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
648 scan_profiles_path: tmp.join("scan_profiles.json"),
649 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
650 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
651 confluence_path: tmp.join("confluence_config.json"),
652 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
653 watched_dirs_path: tmp.join("watched_dirs.json"),
654 };
655 build_router(state)
656}
657
658pub fn make_test_router_with_key(api_key: &str) -> Router {
660 let tmp = std::env::temp_dir().join("sloc_test_key");
661 let state = AppState {
662 base_config: AppConfig::default(),
663 artifacts: Arc::new(Mutex::new(HashMap::new())),
664 async_runs: Arc::new(Mutex::new(HashMap::new())),
665 registry: Arc::new(Mutex::new(ScanRegistry::default())),
666 registry_path: tmp.join("registry.json"),
667 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
668 server_mode: false,
669 tls_enabled: false,
670 api_keys: vec![secrecy::Secret::new(api_key.to_owned())],
671 rate_limiter: Arc::new(IpRateLimiter::new(
672 Duration::from_mins(1),
673 600,
674 10,
675 Duration::from_hours(1),
676 )),
677 trust_proxy: false,
678 trusted_proxy_ips: vec![],
679 git_clones_dir: tmp.join("git-clones"),
680 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
681 schedules_path: tmp.join("schedules.json"),
682 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
683 scan_profiles_path: tmp.join("scan_profiles.json"),
684 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
685 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
686 confluence_path: tmp.join("confluence_config.json"),
687 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
688 watched_dirs_path: tmp.join("watched_dirs.json"),
689 };
690 build_router(state)
691}
692
693struct RuntimeSecurityConfig {
694 api_keys: Vec<secrecy::Secret<String>>,
695 tls_cert: Option<String>,
696 tls_key: Option<String>,
697 tls_enabled: bool,
698 trust_proxy: bool,
699 trusted_proxy_ips: Vec<IpAddr>,
700 rate_limiter: Arc<IpRateLimiter>,
701}
702
703fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
704 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
705 .or_else(|_| std::env::var("SLOC_API_KEY"))
706 .unwrap_or_default()
707 .split(',')
708 .map(str::trim)
709 .filter(|s| !s.is_empty())
710 .map(|s| secrecy::Secret::new(s.to_owned()))
711 .collect();
712 if server_mode && api_keys.is_empty() {
713 println!(
714 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
715 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
716 );
717 }
718 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
719 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
720 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
721 if server_mode && !tls_enabled {
722 println!(
723 "WARNING: TLS is not configured. Traffic is cleartext. \
724 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
725 or terminate TLS at a reverse proxy (nginx, caddy)."
726 );
727 }
728 if server_mode {
729 println!(
730 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
731 to restrict cross-origin access (comma-separated)."
732 );
733 }
734 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
735 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
736 .unwrap_or_default()
737 .split(',')
738 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
739 .collect();
740 if trust_proxy {
741 if trusted_proxy_ips.is_empty() {
742 println!(
743 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
744 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
745 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
746 );
747 } else {
748 println!(
749 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
750 trusted_proxy_ips
751 .iter()
752 .map(std::string::ToString::to_string)
753 .collect::<Vec<_>>()
754 .join(", ")
755 );
756 }
757 } else if server_mode {
758 println!(
759 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
760 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
761 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
762 enable per-client rate limiting via X-Forwarded-For."
763 );
764 }
765 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
766 println!(
767 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
768 DISABLED for all git operations. Remove this variable before production use."
769 );
770 }
771 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
772 .ok()
773 .and_then(|v| v.parse::<u32>().ok())
774 .unwrap_or(10);
775 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
776 .ok()
777 .and_then(|v| v.parse::<u64>().ok())
778 .unwrap_or(3600);
779 let default_rpm: usize = if server_mode { 120 } else { 600 };
783 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
784 .ok()
785 .and_then(|v| v.parse::<usize>().ok())
786 .unwrap_or(default_rpm);
787 let rate_limiter = Arc::new(IpRateLimiter::new(
788 Duration::from_mins(1),
789 rate_limit_rpm,
790 auth_lockout_threshold,
791 Duration::from_secs(auth_lockout_secs),
792 ));
793 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
794 RuntimeSecurityConfig {
795 api_keys,
796 tls_cert,
797 tls_key,
798 tls_enabled,
799 trust_proxy,
800 trusted_proxy_ips,
801 rate_limiter,
802 }
803}
804
805#[allow(clippy::too_many_lines)]
814pub async fn serve(config: AppConfig) -> Result<()> {
815 let bind_address = config.web.bind_address.clone();
816 let server_mode = config.web.server_mode;
817 let output_root = resolve_output_root(None);
818 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
820 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
821 let mut registry = ScanRegistry::load(®istry_path);
822 registry.prune_stale();
823 let _ = registry.save(®istry_path);
824
825 let sec = load_runtime_security_config(server_mode);
826 spawn_upload_staging_cleanup();
827
828 let git_clones_dir = resolve_git_clones_dir(&output_root);
829 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
830 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
831 let schedules = ScheduleStore::load(&schedules_path);
832 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
833 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
834 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
835 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
836 |_| output_root.join("confluence_config.json"),
837 PathBuf::from,
838 );
839 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
840 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
841 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
842 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
843
844 let state = AppState {
845 base_config: config,
846 artifacts: Arc::new(Mutex::new(HashMap::new())),
847 async_runs: Arc::new(Mutex::new(HashMap::new())),
848 registry: Arc::new(Mutex::new(registry)),
849 registry_path,
850 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
851 server_mode,
852 tls_enabled: sec.tls_enabled,
853 api_keys: sec.api_keys,
854 rate_limiter: sec.rate_limiter,
855 trust_proxy: sec.trust_proxy,
856 trusted_proxy_ips: sec.trusted_proxy_ips,
857 git_clones_dir,
858 schedules: Arc::new(Mutex::new(schedules)),
859 schedules_path,
860 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
861 scan_profiles_path,
862 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
863 confluence: Arc::new(Mutex::new(confluence)),
864 confluence_path,
865 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
866 watched_dirs_path,
867 };
868
869 restart_poll_schedules(&state).await;
870
871 let app = build_router(state.clone());
872
873 let preferred: SocketAddr = bind_address
878 .parse()
879 .with_context(|| format!("invalid bind address: {bind_address}"))?;
880 let (listener, addr) = {
881 let candidates = (0u16..=9).map(|offset| {
882 let mut a = preferred;
883 a.set_port(preferred.port().saturating_add(offset));
884 a
885 });
886 let mut found = None;
887 for candidate in candidates {
888 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
889 found = Some((l, candidate));
890 break;
891 }
892 }
893 found.ok_or_else(|| {
894 anyhow::anyhow!(
895 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
896 bind_address,
897 preferred.port(),
898 preferred.port().saturating_add(9)
899 )
900 })?
901 };
902 if addr != preferred {
903 eprintln!(
904 "NOTE: port {} is blocked by a system socket (Windows zombie); \
905 using {} instead.",
906 preferred.port(),
907 addr.port()
908 );
909 }
910
911 if sec.tls_enabled {
912 let cert_path = sec
913 .tls_cert
914 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
915 let key_path = sec
916 .tls_key
917 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
918 let tls_config = build_tls_config(&cert_path, &key_path)
919 .context("failed to load TLS certificate/key")?;
920 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
921
922 let url = format!("https://{addr}/");
923 println!("OxideSLOC server running at {url} (TLS)");
924 println!("Use Ctrl+C to stop.");
925
926 return serve_tls(listener, app, acceptor, server_mode).await;
927 }
928
929 let url = format!("http://{addr}/");
930 log_startup_url(&url, server_mode);
931
932 axum::serve(
933 listener,
934 app.into_make_service_with_connect_info::<SocketAddr>(),
935 )
936 .with_graceful_shutdown(shutdown_signal(server_mode))
937 .await
938 .context("web server terminated unexpectedly")
939}
940
941fn primary_lan_ip() -> Option<String> {
945 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
946 socket.connect("8.8.8.8:80").ok()?;
947 let addr = socket.local_addr().ok()?;
948 let ip = addr.ip();
949 if ip.is_loopback() {
950 return None;
951 }
952 Some(ip.to_string())
953}
954
955fn log_startup_url(url: &str, server_mode: bool) {
957 if server_mode {
958 println!("OxideSLOC server running at {url}");
959 println!("Use Ctrl+C to stop.");
960 } else {
961 println!("OxideSLOC local web UI running at {url}");
962 println!("Press Ctrl+C to stop the server.");
963 let open_url = url.to_owned();
964 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
965 }
966}
967
968fn open_browser_tab(url: &str) {
970 #[cfg(target_os = "windows")]
971 let _ = std::process::Command::new("cmd")
972 .args(["/c", "start", "", url])
973 .stdout(Stdio::null())
974 .stderr(Stdio::null())
975 .spawn();
976 #[cfg(target_os = "macos")]
977 let _ = std::process::Command::new("open")
978 .arg(url)
979 .stdout(Stdio::null())
980 .stderr(Stdio::null())
981 .spawn();
982 #[cfg(target_os = "linux")]
983 let _ = std::process::Command::new("xdg-open")
984 .arg(url)
985 .stdout(Stdio::null())
986 .stderr(Stdio::null())
987 .spawn();
988}
989
990async fn shutdown_signal(server_mode: bool) {
992 if tokio::signal::ctrl_c().await.is_ok() {
993 println!();
994 if server_mode {
995 println!("Shutting down OxideSLOC server...");
996 } else {
997 println!("Shutting down OxideSLOC local web UI...");
998 }
999 println!("Server stopped cleanly.");
1000 }
1001}
1002
1003fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1005 use rustls_pemfile::{certs, private_key};
1006 use std::io::BufReader;
1007
1008 let cert_bytes =
1009 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1010 let key_bytes =
1011 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1012
1013 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
1014 .collect::<std::result::Result<_, _>>()
1015 .context("failed to parse TLS certificates")?;
1016
1017 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
1018 .context("failed to parse TLS private key")?
1019 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
1020
1021 rustls::ServerConfig::builder()
1022 .with_no_client_auth()
1023 .with_single_cert(cert_chain, key)
1024 .context("failed to build TLS server config")
1025}
1026
1027async fn serve_tls(
1029 listener: tokio::net::TcpListener,
1030 app: Router,
1031 acceptor: tokio_rustls::TlsAcceptor,
1032 server_mode: bool,
1033) -> Result<()> {
1034 use hyper_util::rt::{TokioExecutor, TokioIo};
1035 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1036 use hyper_util::service::TowerToHyperService;
1037 use tower::{Service, ServiceExt};
1038
1039 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1040
1041 loop {
1042 tokio::select! {
1043 biased;
1044 _ = tokio::signal::ctrl_c() => {
1045 println!();
1046 if server_mode {
1047 println!("Shutting down OxideSLOC server...");
1048 } else {
1049 println!("Shutting down OxideSLOC local web UI...");
1050 }
1051 println!("Server stopped cleanly.");
1052 return Ok(());
1053 }
1054 result = listener.accept() => {
1055 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1056 let acceptor = acceptor.clone();
1057 let mut factory = make_svc.clone();
1058
1059 tokio::spawn(async move {
1060 let tls = match acceptor.accept(tcp).await {
1061 Ok(s) => s,
1062 Err(e) => {
1063 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1064 return;
1065 }
1066 };
1067 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1068 Ok(f) => match Service::call(f, peer_addr).await {
1069 Ok(s) => s,
1070 Err(_) => return,
1071 },
1072 Err(_) => return,
1073 };
1074 let io = TokioIo::new(tls);
1075 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1076 .serve_connection(io, TowerToHyperService::new(svc))
1077 .await
1078 {
1079 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1080 }
1081 });
1082 }
1083 }
1084 }
1085}
1086
1087fn build_cors_layer(server_mode: bool) -> CorsLayer {
1090 if server_mode {
1091 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1092 .unwrap_or_default()
1093 .split(',')
1094 .filter(|s| !s.is_empty())
1095 .filter_map(|s| s.trim().parse().ok())
1096 .collect();
1097 if allowed.is_empty() {
1098 return CorsLayer::new();
1099 }
1100 CorsLayer::new()
1101 .allow_origin(AllowOrigin::list(allowed))
1102 .allow_methods(AllowMethods::list([
1103 axum::http::Method::GET,
1104 axum::http::Method::POST,
1105 ]))
1106 .allow_headers(AllowHeaders::list([
1107 axum::http::header::AUTHORIZATION,
1108 axum::http::header::CONTENT_TYPE,
1109 ]))
1110 } else {
1111 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1112 let s = origin.to_str().unwrap_or("");
1113 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1114 }))
1115 }
1116}
1117
1118async fn add_security_headers(
1119 State(state): State<AppState>,
1120 mut req: Request<Body>,
1121 next: Next,
1122) -> Response {
1123 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1124 req.extensions_mut().insert(CspNonce(nonce.clone()));
1125 let mut resp = next.run(req).await;
1126 let h = resp.headers_mut();
1127 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1128 h.insert(
1129 "X-Content-Type-Options",
1130 HeaderValue::from_static("nosniff"),
1131 );
1132 h.insert(
1133 "Referrer-Policy",
1134 HeaderValue::from_static("strict-origin-when-cross-origin"),
1135 );
1136 let csp = format!(
1137 "default-src 'self'; \
1138 style-src 'self' 'unsafe-inline'; \
1139 img-src 'self' data: blob:; \
1140 script-src 'self' 'nonce-{nonce}'; \
1141 font-src 'self' data:; \
1142 object-src 'none'; \
1143 frame-ancestors 'none'"
1144 );
1145 h.insert(
1146 "Content-Security-Policy",
1147 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1148 HeaderValue::from_static(
1149 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1150 )
1151 }),
1152 );
1153 h.insert(
1154 "X-Permitted-Cross-Domain-Policies",
1155 HeaderValue::from_static("none"),
1156 );
1157 h.insert(
1158 "Permissions-Policy",
1159 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1160 );
1161 h.insert(
1162 "Cross-Origin-Opener-Policy",
1163 HeaderValue::from_static("same-origin"),
1164 );
1165 h.insert(
1166 "Cross-Origin-Resource-Policy",
1167 HeaderValue::from_static("same-origin"),
1168 );
1169 if state.tls_enabled {
1170 h.insert(
1171 "Strict-Transport-Security",
1172 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1173 );
1174 }
1175 resp
1176}
1177
1178async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1179 let peer_ip = req
1180 .extensions()
1181 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1182 .map(|c| c.0.ip());
1183
1184 let ip = peer_ip
1188 .and_then(|peer| {
1189 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1190 req.headers()
1191 .get("X-Forwarded-For")
1192 .and_then(|v| v.to_str().ok())
1193 .and_then(|s| s.split(',').next())
1194 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1195 } else {
1196 None
1197 }
1198 })
1199 .or(peer_ip)
1200 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1201
1202 if !state.rate_limiter.is_allowed(ip) {
1203 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1204 path = %req.uri().path(), "Rate limit exceeded");
1205 return (
1206 StatusCode::TOO_MANY_REQUESTS,
1207 [(header::RETRY_AFTER, "60")],
1208 "429 Too Many Requests\n",
1209 )
1210 .into_response();
1211 }
1212 next.run(req).await
1213}
1214
1215async fn splash(
1216 State(state): State<AppState>,
1217 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1218) -> impl IntoResponse {
1219 let lan_ip = if state.server_mode {
1220 primary_lan_ip()
1221 } else {
1222 None
1223 };
1224 let port = state
1225 .base_config
1226 .web
1227 .bind_address
1228 .rsplit(':')
1229 .next()
1230 .and_then(|p| p.parse::<u16>().ok())
1231 .unwrap_or(4317);
1232 let has_api_key = !state.api_keys.is_empty();
1233 let template = SplashTemplate {
1234 csp_nonce,
1235 server_mode: state.server_mode,
1236 lan_ip,
1237 port,
1238 version: env!("CARGO_PKG_VERSION"),
1239 has_api_key,
1240 };
1241 Html(
1242 template
1243 .render()
1244 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1245 )
1246}
1247
1248async fn index(
1249 State(state): State<AppState>,
1250 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1251 Query(query): Query<IndexQuery>,
1252) -> impl IntoResponse {
1253 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1254 let policy = query
1255 .mixed_line_policy
1256 .unwrap_or_else(|| "code_only".to_string());
1257 let behavior = query
1258 .binary_file_behavior
1259 .unwrap_or_else(|| "skip".to_string());
1260 let cfg = ScanConfig {
1261 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1262 path: query.path.unwrap_or_default(),
1263 include_globs: query.include_globs.unwrap_or_default(),
1264 exclude_globs: query.exclude_globs.unwrap_or_default(),
1265 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1266 mixed_line_policy: policy,
1267 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1268 != Some("off"),
1269 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1270 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1271 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1272 != Some("disabled"),
1273 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1274 binary_file_behavior: behavior,
1275 output_dir: query.output_dir.unwrap_or_default(),
1276 report_title: query.report_title.unwrap_or_default(),
1277 generate_html: query.generate_html.as_deref() != Some("off"),
1278 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1279 };
1280 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1281 } else {
1282 "{}".to_string()
1283 };
1284
1285 let git_repo = query.git_repo.unwrap_or_default();
1286 let git_ref = query.git_ref.unwrap_or_default();
1287
1288 let git_label = make_git_label(&git_repo, &git_ref);
1289 let git_output_dir = if git_label.is_empty() {
1290 String::new()
1291 } else {
1292 desktop_dir().join(&git_label).display().to_string()
1293 };
1294 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1295 let git_output_dir_json =
1296 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1297
1298 let template = IndexTemplate {
1299 version: env!("CARGO_PKG_VERSION"),
1300 prefill_json,
1301 csp_nonce,
1302 git_repo,
1303 git_ref,
1304 git_label_json,
1305 git_output_dir_json,
1306 server_mode: state.server_mode,
1307 };
1308
1309 Html(
1310 template
1311 .render()
1312 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1313 )
1314}
1315
1316async fn scan_setup_handler(
1317 State(state): State<AppState>,
1318 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1319) -> impl IntoResponse {
1320 let recent_scans_json = {
1321 let arr: Vec<serde_json::Value> = {
1322 let reg = state.registry.lock().await;
1323 reg.entries
1324 .iter()
1325 .rev()
1326 .take(6)
1327 .map(|e| {
1328 let run_dir = e
1329 .html_path
1330 .as_ref()
1331 .or(e.json_path.as_ref())
1332 .and_then(|p| p.parent().map(PathBuf::from));
1333 let config_val: Option<serde_json::Value> = run_dir
1334 .and_then(|d| find_scan_config_in_dir(&d))
1335 .and_then(|p| fs::read_to_string(&p).ok())
1336 .and_then(|s| serde_json::from_str(&s).ok());
1337 serde_json::json!({
1338 "project_label": e.project_label,
1339 "timestamp": fmt_la_time(e.timestamp_utc),
1340 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1341 "config": config_val,
1342 })
1343 })
1344 .collect()
1345 };
1346 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1347 };
1348
1349 let template = ScanSetupTemplate {
1350 version: env!("CARGO_PKG_VERSION"),
1351 recent_scans_json,
1352 csp_nonce,
1353 };
1354 Html(
1355 template
1356 .render()
1357 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1358 )
1359}
1360
1361async fn healthz() -> &'static str {
1362 "ok"
1363}
1364
1365async fn api_version_handler() -> impl IntoResponse {
1366 axum::Json(serde_json::json!({
1367 "name": "oxide-sloc",
1368 "version": env!("CARGO_PKG_VERSION"),
1369 }))
1370}
1371
1372fn prom_runs_total() -> &'static prometheus::IntCounter {
1375 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1376 COUNTER.get_or_init(|| {
1377 prometheus::register_int_counter!(
1378 "oxide_sloc_runs_total",
1379 "Total number of completed analysis runs"
1380 )
1381 .expect("failed to register oxide_sloc_runs_total counter")
1382 })
1383}
1384
1385async fn metrics_handler() -> impl IntoResponse {
1386 use prometheus::Encoder as _;
1387 let mut buf = Vec::new();
1388 let encoder = prometheus::TextEncoder::new();
1389 let _ = encoder.encode(&prometheus::gather(), &mut buf);
1390 (
1391 [(
1392 axum::http::header::CONTENT_TYPE,
1393 "text/plain; version=0.0.4; charset=utf-8",
1394 )],
1395 buf,
1396 )
1397}
1398
1399static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1400
1401async fn openapi_yaml_handler() -> impl IntoResponse {
1402 (
1403 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1404 OPENAPI_YAML,
1405 )
1406}
1407
1408async fn api_docs_handler(
1409 State(state): State<AppState>,
1410 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1411) -> impl IntoResponse {
1412 let has_api_key = !state.api_keys.is_empty();
1413 Html(
1414 ApiDocsTemplate {
1415 has_api_key,
1416 csp_nonce,
1417 version: env!("CARGO_PKG_VERSION"),
1418 }
1419 .render()
1420 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1421 )
1422}
1423
1424async fn chart_js_handler() -> impl IntoResponse {
1425 (
1426 [
1427 (
1428 header::CONTENT_TYPE,
1429 "application/javascript; charset=utf-8",
1430 ),
1431 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1432 ],
1433 CHART_JS,
1434 )
1435}
1436
1437async fn report_chart_js_handler() -> impl IntoResponse {
1438 (
1439 [
1440 (
1441 header::CONTENT_TYPE,
1442 "application/javascript; charset=utf-8",
1443 ),
1444 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1445 ],
1446 REPORT_CHART_JS,
1447 )
1448}
1449
1450#[derive(Debug, Deserialize)]
1451struct AnalyzeForm {
1452 path: String,
1453 git_repo: Option<String>,
1454 git_ref: Option<String>,
1455 mixed_line_policy: Option<MixedLinePolicy>,
1456 python_docstrings_as_comments: Option<String>,
1457 generated_file_detection: Option<String>,
1458 minified_file_detection: Option<String>,
1459 vendor_directory_detection: Option<String>,
1460 include_lockfiles: Option<String>,
1461 binary_file_behavior: Option<BinaryFileBehavior>,
1462 output_dir: Option<String>,
1463 report_title: Option<String>,
1464 report_header_footer: Option<String>,
1465 generate_html: Option<String>,
1466 generate_pdf: Option<String>,
1467 include_globs: Option<String>,
1468 exclude_globs: Option<String>,
1469 submodule_breakdown: Option<String>,
1470 coverage_file: Option<String>,
1471 continuation_line_policy: Option<ContinuationLinePolicy>,
1472 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1473 count_compiler_directives: Option<String>,
1474}
1475
1476#[allow(clippy::struct_excessive_bools)]
1477#[derive(Debug, Serialize, Deserialize, Clone)]
1478struct ScanConfig {
1479 oxide_sloc_version: String,
1480 path: String,
1481 include_globs: String,
1482 exclude_globs: String,
1483 submodule_breakdown: bool,
1484 mixed_line_policy: String,
1485 python_docstrings_as_comments: bool,
1486 generated_file_detection: bool,
1487 minified_file_detection: bool,
1488 vendor_directory_detection: bool,
1489 include_lockfiles: bool,
1490 binary_file_behavior: String,
1491 output_dir: String,
1492 report_title: String,
1493 generate_html: bool,
1494 generate_pdf: bool,
1495}
1496
1497#[derive(Debug, Deserialize, Default)]
1498struct IndexQuery {
1499 path: Option<String>,
1500 include_globs: Option<String>,
1501 exclude_globs: Option<String>,
1502 submodule_breakdown: Option<String>,
1503 mixed_line_policy: Option<String>,
1504 python_docstrings_as_comments: Option<String>,
1505 generated_file_detection: Option<String>,
1506 minified_file_detection: Option<String>,
1507 vendor_directory_detection: Option<String>,
1508 include_lockfiles: Option<String>,
1509 binary_file_behavior: Option<String>,
1510 output_dir: Option<String>,
1511 report_title: Option<String>,
1512 generate_html: Option<String>,
1513 generate_pdf: Option<String>,
1514 prefilled: Option<String>,
1515 git_repo: Option<String>,
1516 git_ref: Option<String>,
1517}
1518
1519#[derive(Debug, Deserialize)]
1520struct PreviewQuery {
1521 path: Option<String>,
1522 include_globs: Option<String>,
1523 exclude_globs: Option<String>,
1524}
1525
1526#[cfg(feature = "native-dialog")]
1527#[derive(Debug, Deserialize)]
1528struct PickDirectoryQuery {
1529 kind: Option<String>,
1530 current: Option<String>,
1531}
1532
1533#[cfg(not(feature = "native-dialog"))]
1534#[derive(Debug, Deserialize)]
1535struct PickDirectoryQuery {}
1536
1537#[derive(Debug, Deserialize, Default)]
1538struct ArtifactQuery {
1539 download: Option<String>,
1540}
1541
1542#[cfg(feature = "native-dialog")]
1543#[derive(Debug, Serialize)]
1544struct PickDirectoryResponse {
1545 selected_path: Option<String>,
1546 cancelled: bool,
1547}
1548
1549#[cfg(feature = "native-dialog")]
1550async fn pick_directory_handler(
1551 State(state): State<AppState>,
1552 Query(query): Query<PickDirectoryQuery>,
1553) -> Response {
1554 if state.server_mode {
1555 return StatusCode::NOT_FOUND.into_response();
1556 }
1557
1558 let is_coverage = query.kind.as_deref() == Some("coverage");
1559 let title = match query.kind.as_deref() {
1560 Some("output") => "Select output directory",
1561 Some("reports") => "Select folder containing saved reports",
1562 Some("coverage") => "Select LCOV coverage file",
1563 _ => "Select project directory",
1564 }
1565 .to_owned();
1566 let current = query.current.clone();
1567
1568 let picked = tokio::task::spawn_blocking(move || {
1569 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1572 let fg_tid = win_dialog_focus::attach_to_foreground();
1573 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1574 win_dialog_focus::flash_dialog_when_ready(title.clone());
1575
1576 let mut dialog = rfd::FileDialog::new().set_title(&title);
1577 if let Some(current) = current.as_deref() {
1578 let resolved = resolve_input_path(current);
1579 let seed = if resolved.is_dir() {
1580 Some(resolved)
1581 } else {
1582 resolved.parent().map(Path::to_path_buf)
1583 };
1584 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1585 dialog = dialog.set_directory(seed_dir);
1586 }
1587 }
1588 let result = if is_coverage {
1589 dialog
1590 .add_filter(
1591 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1592 &["info", "lcov", "xml"],
1593 )
1594 .pick_file()
1595 } else {
1596 dialog.pick_folder()
1597 };
1598
1599 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1600 win_dialog_focus::detach_from_foreground(fg_tid);
1601
1602 result
1603 })
1604 .await
1605 .unwrap_or(None);
1606
1607 Json(PickDirectoryResponse {
1608 selected_path: picked.as_ref().map(|p| display_path(p)),
1609 cancelled: picked.is_none(),
1610 })
1611 .into_response()
1612}
1613
1614#[cfg(not(feature = "native-dialog"))]
1615async fn pick_directory_handler(
1616 State(_state): State<AppState>,
1617 Query(_query): Query<PickDirectoryQuery>,
1618) -> Response {
1619 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1620}
1621
1622#[cfg(feature = "native-dialog")]
1623async fn pick_file_handler(State(state): State<AppState>) -> Response {
1624 if state.server_mode {
1625 return StatusCode::NOT_FOUND.into_response();
1626 }
1627 let picked = tokio::task::spawn_blocking(|| {
1628 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1629 let fg_tid = win_dialog_focus::attach_to_foreground();
1630 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1631 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1632
1633 let result = rfd::FileDialog::new()
1634 .set_title("Select HTML report")
1635 .add_filter("HTML report", &["html"])
1636 .pick_file();
1637
1638 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1639 win_dialog_focus::detach_from_foreground(fg_tid);
1640
1641 result
1642 })
1643 .await
1644 .unwrap_or(None);
1645 Json(PickDirectoryResponse {
1646 selected_path: picked.as_ref().map(|p| display_path(p)),
1647 cancelled: picked.is_none(),
1648 })
1649 .into_response()
1650}
1651
1652#[cfg(not(feature = "native-dialog"))]
1653async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1654 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1655}
1656
1657fn is_upload_tmp_path(path: &Path) -> bool {
1662 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
1663 path.starts_with(&upload_root)
1664}
1665
1666fn is_sample_path(path: &Path) -> bool {
1669 let root = workspace_root();
1670 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
1671}
1672
1673fn upload_base_dir() -> PathBuf {
1675 std::env::temp_dir().join("oxide-sloc-uploads")
1676}
1677
1678fn upload_staging_path(id: &str) -> PathBuf {
1680 upload_base_dir().join(id)
1681}
1682
1683#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
1687 const MAX_FILES: usize = 50_000;
1688 if body.files.is_empty() {
1689 return Err((
1690 StatusCode::BAD_REQUEST,
1691 Json(serde_json::json!({"error": "No files received"})),
1692 )
1693 .into_response());
1694 }
1695 if body.files.len() > MAX_FILES {
1696 return Err((
1697 StatusCode::PAYLOAD_TOO_LARGE,
1698 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
1699 )
1700 .into_response());
1701 }
1702 Ok(())
1703}
1704
1705fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
1708 match id {
1709 Some(id)
1710 if !id.is_empty()
1711 && id.len() <= 36
1712 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
1713 {
1714 (id.to_string(), upload_staging_path(id))
1715 }
1716 _ => {
1717 let new_id = uuid::Uuid::new_v4().to_string();
1718 let staging = upload_staging_path(&new_id);
1719 (new_id, staging)
1720 }
1721 }
1722}
1723
1724#[allow(clippy::result_large_err)]
1729async fn stage_decoded_entry(
1730 entry: &UploadedFile,
1731 staging: &Path,
1732 total_bytes: &mut usize,
1733 project_root: &mut Option<PathBuf>,
1734) -> Result<(), Response> {
1735 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
1736
1737 let Ok(data) = base64::Engine::decode(
1738 &base64::engine::general_purpose::STANDARD,
1739 entry.content.as_bytes(),
1740 ) else {
1741 return Ok(());
1742 };
1743
1744 *total_bytes += data.len();
1745 if *total_bytes > MAX_TOTAL_BYTES {
1746 return Err((
1747 StatusCode::PAYLOAD_TOO_LARGE,
1748 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
1749 )
1750 .into_response());
1751 }
1752
1753 let rel = std::path::Path::new(&entry.path);
1754 if project_root.is_none() {
1755 if let Some(first) = rel.components().next() {
1756 *project_root = Some(staging.join(first.as_os_str()));
1757 }
1758 }
1759
1760 let dest = staging.join(rel);
1761 if let Some(parent) = dest.parent() {
1762 if tokio::fs::create_dir_all(parent).await.is_err() {
1763 return Err((
1764 StatusCode::INTERNAL_SERVER_ERROR,
1765 Json(serde_json::json!({"error": "Failed to create directory structure"})),
1766 )
1767 .into_response());
1768 }
1769 }
1770
1771 if tokio::fs::write(&dest, &data).await.is_err() {
1772 return Err((
1773 StatusCode::INTERNAL_SERVER_ERROR,
1774 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
1775 )
1776 .into_response());
1777 }
1778
1779 Ok(())
1780}
1781
1782async fn write_upload_files(
1786 files: &[UploadedFile],
1787 staging: &Path,
1788 upload_id: &str,
1789) -> Result<(usize, Option<PathBuf>), Response> {
1790 let mut total_bytes: usize = 0;
1791 let mut project_root: Option<PathBuf> = None;
1792 let mut traversal_attempts: usize = 0;
1793
1794 for entry in files {
1795 let rel = std::path::Path::new(&entry.path);
1796 if rel
1797 .components()
1798 .any(|c| matches!(c, std::path::Component::ParentDir))
1799 {
1800 traversal_attempts += 1;
1801 if traversal_attempts >= 5 {
1802 let _ = tokio::fs::remove_dir_all(staging).await;
1803 tracing::warn!(
1804 event = "upload_path_traversal",
1805 upload_id = %upload_id,
1806 "Upload rejected: repeated path traversal attempts detected"
1807 );
1808 return Err((
1809 StatusCode::BAD_REQUEST,
1810 Json(serde_json::json!({"error": "Upload rejected"})),
1811 )
1812 .into_response());
1813 }
1814 continue;
1815 }
1816
1817 if let Err(resp) =
1818 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
1819 {
1820 let _ = tokio::fs::remove_dir_all(staging).await;
1821 return Err(resp);
1822 }
1823 }
1824
1825 Ok((files.len(), project_root))
1826}
1827
1828fn parse_tarball_size_caps() -> (u64, u64) {
1831 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
1832 .ok()
1833 .and_then(|v| v.parse().ok())
1834 .unwrap_or(2048_u64)
1835 * 1024
1836 * 1024;
1837 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
1838 .ok()
1839 .and_then(|v| v.parse().ok())
1840 .unwrap_or(10_240_u64)
1841 * 1024
1842 * 1024;
1843 (compressed, decompressed)
1844}
1845
1846#[allow(clippy::result_large_err)] async fn stream_body_to_file(
1851 body: axum::body::Body,
1852 dest_path: &Path,
1853 max_bytes: u64,
1854) -> Result<u64, Response> {
1855 use http_body_util::BodyExt as _;
1856 use tokio::io::AsyncWriteExt as _;
1857
1858 let mut file = match tokio::fs::File::create(dest_path).await {
1859 Ok(f) => f,
1860 Err(e) => {
1861 tracing::error!(
1862 event = "upload_io_error",
1863 "failed to create tarball temp file: {e}"
1864 );
1865 return Err((
1866 StatusCode::INTERNAL_SERVER_ERROR,
1867 Json(serde_json::json!({"error": "Upload initialization failed"})),
1868 )
1869 .into_response());
1870 }
1871 };
1872
1873 let mut body = body;
1874 let mut written: u64 = 0;
1875 loop {
1876 match body.frame().await {
1877 None => break,
1878 Some(Err(e)) => {
1879 let _ = tokio::fs::remove_file(dest_path).await;
1880 return Err((
1881 StatusCode::BAD_REQUEST,
1882 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
1883 )
1884 .into_response());
1885 }
1886 Some(Ok(frame)) => {
1887 if let Ok(data) = frame.into_data() {
1888 written += data.len() as u64;
1889 if written > max_bytes {
1890 let _ = tokio::fs::remove_file(dest_path).await;
1891 return Err((
1892 StatusCode::PAYLOAD_TOO_LARGE,
1893 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
1894 )
1895 .into_response());
1896 }
1897 if let Err(e) = file.write_all(&data).await {
1898 let _ = tokio::fs::remove_file(dest_path).await;
1899 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
1900 return Err((
1901 StatusCode::INTERNAL_SERVER_ERROR,
1902 Json(serde_json::json!({"error": "Upload write failed"})),
1903 )
1904 .into_response());
1905 }
1906 }
1907 }
1908 }
1909 }
1910 drop(file);
1911 Ok(written)
1912}
1913
1914#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
1919 tarball_path: &Path,
1920 staging: &Path,
1921 max_decompressed_bytes: u64,
1922) -> Result<(), Response> {
1923 let staging_clone = staging.to_path_buf();
1924 let tarball_clone = tarball_path.to_path_buf();
1925 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
1926 let file = std::fs::File::open(&tarball_clone)?;
1927 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
1928 let limited = SizeLimitReader {
1929 inner: gz,
1930 remaining: max_decompressed_bytes,
1931 };
1932 let mut archive = tar::Archive::new(limited);
1933 archive.set_overwrite(true);
1934 archive.set_preserve_permissions(false);
1935 std::fs::create_dir_all(&staging_clone)?;
1936 archive.unpack(&staging_clone)?;
1937 Ok(())
1938 })
1939 .await;
1940 let _ = tokio::fs::remove_file(tarball_path).await;
1941
1942 match extract_result {
1943 Ok(Ok(())) => Ok(()),
1944 Ok(Err(e)) => {
1945 let _ = tokio::fs::remove_dir_all(staging).await;
1946 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
1947 tracing::warn!(
1948 event = "upload_extract_error",
1949 "tarball extraction failed: {e:#}"
1950 );
1951 let (status, msg) = if is_size_limit {
1952 (
1953 StatusCode::PAYLOAD_TOO_LARGE,
1954 "Archive exceeds the decompressed size limit",
1955 )
1956 } else {
1957 (StatusCode::BAD_REQUEST, "Failed to extract archive")
1958 };
1959 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
1960 }
1961 Err(e) => {
1962 let _ = tokio::fs::remove_dir_all(staging).await;
1963 tracing::error!(
1964 event = "upload_extract_panic",
1965 "tarball extraction task panicked: {e}"
1966 );
1967 Err((
1968 StatusCode::INTERNAL_SERVER_ERROR,
1969 Json(serde_json::json!({"error": "Archive extraction failed"})),
1970 )
1971 .into_response())
1972 }
1973 }
1974}
1975
1976async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
1980 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
1981 let first = entries.next_entry().await.ok()??;
1982 if !first.path().is_dir() {
1983 return None;
1984 }
1985 if entries.next_entry().await.unwrap_or(None).is_some() {
1986 return None;
1987 }
1988 Some(first.path())
1989}
1990
1991#[derive(Deserialize)]
1998struct UploadDirRequest {
1999 files: Vec<UploadedFile>,
2000 upload_id: Option<String>,
2003}
2004
2005#[derive(Deserialize)]
2006struct UploadedFile {
2007 path: String,
2009 content: String,
2011}
2012
2013async fn upload_directory_handler(
2023 State(state): State<AppState>,
2024 Json(body): Json<UploadDirRequest>,
2025) -> Response {
2026 if !state.server_mode {
2027 return StatusCode::NOT_FOUND.into_response();
2028 }
2029 if let Err(resp) = validate_upload_dir_request(&body) {
2030 return resp;
2031 }
2032 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2035 match write_upload_files(&body.files, &staging, &upload_id).await {
2036 Ok((file_count, project_root)) => {
2037 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2038 Json(serde_json::json!({
2039 "tmp_path": scan_root.to_string_lossy(),
2040 "file_count": file_count,
2041 "upload_id": upload_id.clone()
2042 }))
2043 .into_response()
2044 }
2045 Err(resp) => resp,
2046 }
2047}
2048
2049#[derive(Deserialize)]
2051struct UploadFileRequest {
2052 filename: String,
2054 content: String,
2056}
2057
2058async fn upload_file_handler(
2064 State(state): State<AppState>,
2065 Json(body): Json<UploadFileRequest>,
2066) -> Response {
2067 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2070 return StatusCode::NOT_FOUND.into_response();
2071 }
2072
2073 let Ok(data) = base64::Engine::decode(
2074 &base64::engine::general_purpose::STANDARD,
2075 body.content.as_bytes(),
2076 ) else {
2077 return (
2078 StatusCode::BAD_REQUEST,
2079 Json(serde_json::json!({"error": "Invalid base64 content"})),
2080 )
2081 .into_response();
2082 };
2083
2084 if data.len() > MAX_FILE_BYTES {
2085 return (
2086 StatusCode::PAYLOAD_TOO_LARGE,
2087 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2088 )
2089 .into_response();
2090 }
2091
2092 let filename = std::path::Path::new(&body.filename)
2094 .file_name()
2095 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2096
2097 let upload_id = uuid::Uuid::new_v4();
2098 let staging = std::env::temp_dir()
2099 .join("oxide-sloc-uploads")
2100 .join(upload_id.to_string());
2101
2102 if tokio::fs::create_dir_all(&staging).await.is_err() {
2103 return (
2104 StatusCode::INTERNAL_SERVER_ERROR,
2105 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2106 )
2107 .into_response();
2108 }
2109
2110 let dest = staging.join(&filename);
2111 if tokio::fs::write(&dest, &data).await.is_err() {
2112 let _ = tokio::fs::remove_dir_all(&staging).await;
2113 return (
2114 StatusCode::INTERNAL_SERVER_ERROR,
2115 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2116 )
2117 .into_response();
2118 }
2119
2120 Json(serde_json::json!({
2121 "tmp_path": dest.to_string_lossy(),
2122 "upload_id": upload_id.to_string()
2123 }))
2124 .into_response()
2125}
2126
2127struct SizeLimitReader<R> {
2142 inner: R,
2143 remaining: u64,
2144}
2145impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2146 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2147 if self.remaining == 0 {
2148 return Err(std::io::Error::other("decompressed size limit exceeded"));
2149 }
2150 let n = self.inner.read(buf)?;
2151 self.remaining = self.remaining.saturating_sub(n as u64);
2152 Ok(n)
2153 }
2154}
2155
2156async fn upload_tarball_handler(
2157 State(state): State<AppState>,
2158 request: axum::extract::Request,
2159) -> Response {
2160 if !state.server_mode {
2161 return StatusCode::NOT_FOUND.into_response();
2162 }
2163
2164 let upload_id = uuid::Uuid::new_v4().to_string();
2165 let upload_base = upload_base_dir();
2166 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2167 let staging = upload_staging_path(&upload_id);
2168 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2169
2170 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2171 tracing::error!(
2172 event = "upload_io_error",
2173 "failed to create upload base dir: {e}"
2174 );
2175 return (
2176 StatusCode::INTERNAL_SERVER_ERROR,
2177 Json(serde_json::json!({"error": "Upload initialization failed"})),
2178 )
2179 .into_response();
2180 }
2181
2182 let compressed_bytes =
2184 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2185 Ok(n) => n,
2186 Err(resp) => return resp,
2187 };
2188
2189 if let Err(resp) =
2191 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2192 {
2193 return resp;
2194 }
2195
2196 let scan_root = find_single_top_dir(&staging)
2201 .await
2202 .unwrap_or_else(|| staging.clone());
2203
2204 let original_bytes = tokio::task::spawn_blocking({
2206 let p = scan_root.clone();
2207 move || dir_size_bytes(&p)
2208 })
2209 .await
2210 .unwrap_or(0);
2211
2212 Json(serde_json::json!({
2213 "tmp_path": scan_root.to_string_lossy(),
2214 "upload_id": upload_id,
2215 "compressed_bytes": compressed_bytes,
2216 "original_bytes": original_bytes,
2217 }))
2218 .into_response()
2219}
2220
2221#[derive(Deserialize)]
2222struct LocateReportForm {
2223 file_path: String,
2224}
2225
2226fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2228 let html = ErrorTemplate {
2229 message: message.into(),
2230 last_report_url: Some("/view-reports".to_string()),
2231 last_report_label: Some("View Reports".to_string()),
2232 run_id: None,
2233 error_code: None,
2234 csp_nonce: csp_nonce.to_owned(),
2235 version: env!("CARGO_PKG_VERSION"),
2236 }
2237 .render()
2238 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2239 Html(html).into_response()
2240}
2241
2242fn registry_entry_from_run(
2244 run: &AnalysisRun,
2245 json_path: PathBuf,
2246 html_path: PathBuf,
2247) -> RegistryEntry {
2248 let project_label = run.input_roots.first().map_or_else(
2249 || "Unknown Project".to_string(),
2250 |r| sanitize_project_label(r),
2251 );
2252 RegistryEntry {
2253 run_id: run.tool.run_id.clone(),
2254 timestamp_utc: run.tool.timestamp_utc,
2255 project_label,
2256 input_roots: run.input_roots.clone(),
2257 json_path: Some(json_path),
2258 html_path: Some(html_path),
2259 pdf_path: None,
2260 summary: ScanSummarySnapshot {
2261 files_analyzed: run.summary_totals.files_analyzed,
2262 files_skipped: run.summary_totals.files_skipped,
2263 total_physical_lines: run.summary_totals.total_physical_lines,
2264 code_lines: run.summary_totals.code_lines,
2265 comment_lines: run.summary_totals.comment_lines,
2266 blank_lines: run.summary_totals.blank_lines,
2267 functions: run.summary_totals.functions,
2268 classes: run.summary_totals.classes,
2269 variables: run.summary_totals.variables,
2270 imports: run.summary_totals.imports,
2271 test_count: run.summary_totals.test_count,
2272 coverage_lines_found: run.summary_totals.coverage_lines_found,
2273 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2274 coverage_functions_found: run.summary_totals.coverage_functions_found,
2275 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2276 coverage_branches_found: run.summary_totals.coverage_branches_found,
2277 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2278 },
2279 csv_path: None,
2280 xlsx_path: None,
2281 git_branch: None,
2282 git_commit: None,
2283 git_author: None,
2284 git_tags: None,
2285 git_nearest_tag: None,
2286 git_commit_date: None,
2287 }
2288}
2289
2290pub(crate) async fn register_artifacts_in_registry(
2293 state: &AppState,
2294 label: &str,
2295 run: &AnalysisRun,
2296 artifacts: &RunArtifacts,
2297) {
2298 let Some(json_path) = artifacts.json_path.clone() else {
2299 return;
2300 };
2301 let Some(html_path) = artifacts.html_path.clone() else {
2302 return;
2303 };
2304 let mut entry = registry_entry_from_run(run, json_path, html_path);
2305 entry.project_label = label.to_owned();
2306 let mut reg = state.registry.lock().await;
2307 reg.add_entry(entry);
2308 let _ = reg.save(&state.registry_path);
2309}
2310
2311#[allow(clippy::result_large_err)]
2316fn validate_locate_request(
2317 state: &AppState,
2318 file_path: &str,
2319 csp_nonce: &str,
2320) -> Result<(PathBuf, PathBuf), Response> {
2321 let file_ext = Path::new(file_path)
2322 .extension()
2323 .and_then(|e| e.to_str())
2324 .unwrap_or("")
2325 .to_ascii_lowercase();
2326 if file_ext != "html" {
2327 return Err(locate_report_error(
2328 "Only .html report files can be located via this form.",
2329 csp_nonce,
2330 ));
2331 }
2332 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
2333 Ok(p) => strip_unc_prefix(p),
2334 Err(_) => {
2335 return Err(locate_report_error(
2336 "Report file not found or path is invalid.",
2337 csp_nonce,
2338 ));
2339 }
2340 };
2341 if state.server_mode {
2342 let output_root = resolve_output_root(None);
2343 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2344 if !html_path.starts_with(&canonical_root) {
2345 return Err(locate_report_error(
2346 "Report file must be within the configured output directory.",
2347 csp_nonce,
2348 ));
2349 }
2350 }
2351 let parent = match html_path.parent() {
2352 Some(p) => p.to_path_buf(),
2353 None => {
2354 return Err(locate_report_error(
2355 "Report file has no parent directory.",
2356 csp_nonce,
2357 ));
2358 }
2359 };
2360 Ok((html_path, parent))
2361}
2362
2363fn locate_path_hint(server_mode: bool, path: &Path) -> String {
2365 if server_mode {
2366 String::new()
2367 } else {
2368 format!("\n\nFile: {}", path.display())
2369 }
2370}
2371
2372async fn locate_report_handler(
2373 State(state): State<AppState>,
2374 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2375 Form(form): Form<LocateReportForm>,
2376) -> impl IntoResponse {
2377 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2378 Ok(v) => v,
2379 Err(resp) => return resp,
2380 };
2381
2382 let json_candidate = parent.join("result.json");
2383 let mut reg = state.registry.lock().await;
2384 let entry_idx = reg.entries.iter().position(|e| {
2386 let json_match = e
2387 .json_path
2388 .as_ref()
2389 .and_then(|p| p.parent())
2390 .is_some_and(|p| p == parent);
2391 let html_match = e
2392 .html_path
2393 .as_ref()
2394 .and_then(|p| p.parent())
2395 .is_some_and(|p| p == parent);
2396 json_match || html_match
2397 });
2398 if let Some(idx) = entry_idx {
2399 reg.entries[idx].html_path = Some(html_path);
2400 let _ = reg.save(&state.registry_path);
2401 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2402 }
2403 if json_candidate.exists() {
2405 match read_json(&json_candidate) {
2406 Ok(run) => {
2407 let entry = registry_entry_from_run(&run, json_candidate, html_path);
2408 reg.add_entry(entry);
2409 let _ = reg.save(&state.registry_path);
2410 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2411 }
2412 Err(e) => {
2413 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
2414 let err_detail = if state.server_mode {
2415 String::new()
2416 } else {
2417 format!("\n\nError: {e}")
2418 };
2419 return locate_report_error(
2420 format!(
2421 "Could not link this report.\n\nA 'result.json' was found but could not \
2422 be parsed — it may have been saved by an older version of OxideSLOC. \
2423 Re-running the analysis will create a fresh, compatible \
2424 record.{file_hint}{err_detail}"
2425 ),
2426 &csp_nonce,
2427 );
2428 }
2429 }
2430 }
2431 drop(reg);
2432 let file_hint = locate_path_hint(state.server_mode, &html_path);
2433 locate_report_error(
2434 format!(
2435 "Could not link this report.\n\nNo matching scan record was found, and no \
2436 'result.json' was found in the same folder.{file_hint}"
2437 ),
2438 &csp_nonce,
2439 )
2440}
2441
2442fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2444 fs::read_dir(dir)
2445 .ok()?
2446 .flatten()
2447 .map(|e| e.path())
2448 .find(|p| {
2449 p.is_file()
2450 && p.file_stem()
2451 .and_then(|n| n.to_str())
2452 .is_some_and(|n| n.starts_with("result"))
2453 && p.extension()
2454 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2455 })
2456}
2457
2458#[derive(Deserialize)]
2459struct LocateReportsDirForm {
2460 folder_path: String,
2461}
2462
2463#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
2465 State(state): State<AppState>,
2466 Form(form): Form<LocateReportsDirForm>,
2467) -> impl IntoResponse {
2468 if state.server_mode {
2469 return StatusCode::NOT_FOUND.into_response();
2470 }
2471 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2472 Ok(p) => strip_unc_prefix(p),
2473 Err(_) => {
2474 return axum::response::Redirect::to(
2475 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2476 )
2477 .into_response();
2478 }
2479 };
2480 if !folder.is_dir() {
2481 return axum::response::Redirect::to(
2482 "/view-reports?error=Selected+path+is+not+a+directory.",
2483 )
2484 .into_response();
2485 }
2486
2487 let candidates = collect_result_json_candidates(&folder);
2488
2489 if candidates.is_empty() {
2490 return axum::response::Redirect::to(
2491 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2492 )
2493 .into_response();
2494 }
2495
2496 let mut linked_count: usize = 0;
2497 let mut reg = state.registry.lock().await;
2498 for json_path in candidates {
2499 let Some(parent) = json_path.parent().map(PathBuf::from) else {
2500 continue;
2501 };
2502 if is_dir_already_registered(®, &parent) {
2503 continue;
2504 }
2505 let Some(entry) = build_registry_entry_from_json(json_path) else {
2506 continue;
2507 };
2508 reg.add_entry(entry);
2509 linked_count += 1;
2510 }
2511 let _ = reg.save(&state.registry_path);
2512 drop(reg);
2513
2514 if linked_count == 0 {
2515 return axum::response::Redirect::to(
2516 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2517 )
2518 .into_response();
2519 }
2520 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2521}
2522
2523#[derive(Deserialize)]
2524struct RelocateScanForm {
2525 run_id: String,
2526 folder_path: String,
2527 redirect_url: String,
2528}
2529
2530async fn relocate_scan_handler(
2531 State(state): State<AppState>,
2532 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2533 Form(form): Form<RelocateScanForm>,
2534) -> impl IntoResponse {
2535 if state.server_mode {
2536 return StatusCode::NOT_FOUND.into_response();
2537 }
2538
2539 let run_id = form.run_id.trim().to_string();
2540 let redirect_url = form.redirect_url.trim().to_string();
2541
2542 let run_exists = {
2543 let reg = state.registry.lock().await;
2544 reg.find_by_run_id(&run_id).is_some()
2545 };
2546 if !run_exists {
2547 let html = ErrorTemplate {
2548 message: format!("Run ID '{run_id}' not found in registry."),
2549 last_report_url: Some("/compare-scans".to_string()),
2550 last_report_label: Some("Compare Scans".to_string()),
2551 run_id: Some(run_id.clone()),
2552 error_code: Some(404),
2553 csp_nonce: csp_nonce.clone(),
2554 version: env!("CARGO_PKG_VERSION"),
2555 }
2556 .render()
2557 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2558 return Html(html).into_response();
2559 }
2560
2561 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2562 Ok(p) => strip_unc_prefix(p),
2563 Err(_) => {
2564 return missing_scan_relocate_response(
2565 "Folder not found or path is invalid.",
2566 &run_id,
2567 form.folder_path.trim(),
2568 &redirect_url,
2569 false,
2570 &csp_nonce,
2571 );
2572 }
2573 };
2574 if !folder.is_dir() {
2575 return missing_scan_relocate_response(
2576 "Selected path is not a directory.",
2577 &run_id,
2578 &folder.display().to_string(),
2579 &redirect_url,
2580 false,
2581 &csp_nonce,
2582 );
2583 }
2584
2585 let json_candidates = find_result_files_by_ext(&folder, "json");
2586 if json_candidates.is_empty() {
2587 return missing_scan_relocate_response(
2588 &format!(
2589 "No result JSON files found in the selected folder.\nSearched: {}",
2590 folder.display()
2591 ),
2592 &run_id,
2593 &folder.display().to_string(),
2594 &redirect_url,
2595 false,
2596 &csp_nonce,
2597 );
2598 }
2599
2600 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
2601 return missing_scan_relocate_response(
2602 &format!(
2603 "No matching scan found in the selected folder.\n\
2604 The JSON files present do not contain run ID: {run_id}\n\
2605 Searched: {}",
2606 folder.display()
2607 ),
2608 &run_id,
2609 &folder.display().to_string(),
2610 &redirect_url,
2611 false,
2612 &csp_nonce,
2613 );
2614 };
2615
2616 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
2617 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
2618 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
2619
2620 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2621 redirect_url
2622 } else {
2623 "/compare-scans".to_string()
2624 };
2625 axum::response::Redirect::to(&safe_redirect).into_response()
2626}
2627
2628fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
2629 fs::read_dir(folder)
2630 .ok()
2631 .into_iter()
2632 .flatten()
2633 .flatten()
2634 .map(|e| e.path())
2635 .filter(|p| {
2636 p.is_file()
2637 && p.file_stem()
2638 .and_then(|n| n.to_str())
2639 .is_some_and(|n| n.starts_with("result"))
2640 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
2641 })
2642 .collect()
2643}
2644
2645fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
2646 candidates
2647 .iter()
2648 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
2649 .cloned()
2650}
2651
2652async fn update_run_file_paths(
2653 state: &AppState,
2654 run_id: &str,
2655 json_path: PathBuf,
2656 html_path: Option<PathBuf>,
2657 pdf_path: Option<PathBuf>,
2658) {
2659 let mut reg = state.registry.lock().await;
2660 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2661 entry.json_path = Some(json_path);
2662 if let Some(hp) = html_path {
2663 entry.html_path = Some(hp);
2664 }
2665 if let Some(pp) = pdf_path {
2666 entry.pdf_path = Some(pp);
2667 }
2668 }
2669 let _ = reg.save(&state.registry_path);
2670}
2671
2672fn missing_scan_relocate_response(
2673 message: &str,
2674 run_id: &str,
2675 folder_hint: &str,
2676 redirect_url: &str,
2677 server_mode: bool,
2678 csp_nonce: &str,
2679) -> axum::response::Response {
2680 let html = RelocateScanTemplate {
2681 message: message.to_string(),
2682 run_id: run_id.to_string(),
2683 folder_hint: folder_hint.to_string(),
2684 redirect_url: redirect_url.to_string(),
2685 server_mode,
2686 csp_nonce: csp_nonce.to_owned(),
2687 version: env!("CARGO_PKG_VERSION"),
2688 }
2689 .render()
2690 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2691 (StatusCode::NOT_FOUND, Html(html)).into_response()
2692}
2693
2694fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
2698 let mut candidates = Vec::new();
2699 if let Some(j) = find_result_json_in_dir(folder) {
2700 candidates.push(j);
2701 }
2702 if let Ok(dir_entries) = fs::read_dir(folder) {
2703 for entry in dir_entries.flatten() {
2704 let sub = entry.path();
2705 if sub.is_dir() {
2706 if let Some(j) = find_result_json_in_dir(&sub) {
2707 candidates.push(j);
2708 }
2709 }
2710 }
2711 }
2712 candidates
2713}
2714
2715fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
2716 reg.entries.iter().any(|e| {
2717 let dir_match = e
2718 .json_path
2719 .as_ref()
2720 .and_then(|p| p.parent())
2721 .is_some_and(|p| p == parent)
2722 || e.html_path
2723 .as_ref()
2724 .and_then(|p| p.parent())
2725 .is_some_and(|p| p == parent);
2726 dir_match
2727 && (e.json_path.as_ref().is_some_and(|p| p.exists())
2728 || e.html_path.as_ref().is_some_and(|p| p.exists()))
2729 })
2730}
2731
2732fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
2733 let parent = json_path.parent()?.to_path_buf();
2734 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2735 rd.flatten()
2736 .map(|e| e.path())
2737 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2738 });
2739 let run = read_json(&json_path).ok()?;
2740 let project_label = run.input_roots.first().map_or_else(
2741 || "Unknown Project".to_string(),
2742 |r| sanitize_project_label(r),
2743 );
2744 Some(RegistryEntry {
2745 run_id: run.tool.run_id.clone(),
2746 timestamp_utc: run.tool.timestamp_utc,
2747 project_label,
2748 input_roots: run.input_roots.clone(),
2749 json_path: Some(json_path),
2750 html_path,
2751 pdf_path: None,
2752 csv_path: None,
2753 xlsx_path: None,
2754 summary: ScanSummarySnapshot {
2755 files_analyzed: run.summary_totals.files_analyzed,
2756 files_skipped: run.summary_totals.files_skipped,
2757 total_physical_lines: run.summary_totals.total_physical_lines,
2758 code_lines: run.summary_totals.code_lines,
2759 comment_lines: run.summary_totals.comment_lines,
2760 blank_lines: run.summary_totals.blank_lines,
2761 functions: run.summary_totals.functions,
2762 classes: run.summary_totals.classes,
2763 variables: run.summary_totals.variables,
2764 imports: run.summary_totals.imports,
2765 test_count: run.summary_totals.test_count,
2766 coverage_lines_found: run.summary_totals.coverage_lines_found,
2767 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2768 coverage_functions_found: run.summary_totals.coverage_functions_found,
2769 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2770 coverage_branches_found: run.summary_totals.coverage_branches_found,
2771 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2772 },
2773 git_branch: run.git_branch.clone(),
2774 git_commit: run.git_commit_short.clone(),
2775 git_author: run.git_commit_author.clone(),
2776 git_tags: run.git_tags.clone(),
2777 git_nearest_tag: run.git_nearest_tag.clone(),
2778 git_commit_date: run.git_commit_date,
2779 })
2780}
2781
2782fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2785 let mut linked = 0usize;
2786 for json_path in collect_result_json_candidates(folder) {
2787 let Some(parent) = json_path.parent().map(PathBuf::from) else {
2788 continue;
2789 };
2790 if is_dir_already_registered(reg, &parent) {
2791 continue;
2792 }
2793 let Some(entry) = build_registry_entry_from_json(json_path) else {
2794 continue;
2795 };
2796 reg.add_entry(entry);
2797 linked += 1;
2798 }
2799 linked
2800}
2801
2802async fn auto_scan_watched_dirs(state: &AppState) {
2804 let dirs: Vec<PathBuf> = {
2805 let wd = state.watched_dirs.lock().await;
2806 wd.dirs.clone()
2807 };
2808 if dirs.is_empty() {
2809 return;
2810 }
2811 let mut reg = state.registry.lock().await;
2812 let mut total = 0usize;
2813 for dir in &dirs {
2814 if dir.is_dir() {
2815 total += scan_folder_into_registry(dir, &mut reg);
2816 }
2817 }
2818 if total > 0 {
2819 let _ = reg.save(&state.registry_path);
2820 }
2821}
2822
2823#[derive(Deserialize)]
2826struct WatchedDirForm {
2827 folder_path: String,
2828 #[serde(default = "default_redirect")]
2829 redirect_to: String,
2830}
2831
2832fn default_redirect() -> String {
2833 "/view-reports".to_string()
2834}
2835
2836#[derive(Deserialize)]
2837struct WatchedDirRefreshForm {
2838 #[serde(default = "default_redirect")]
2839 redirect_to: String,
2840}
2841
2842fn safe_redirect(dest: &str) -> &str {
2846 if dest.starts_with('/') {
2847 dest
2848 } else {
2849 "/"
2850 }
2851}
2852
2853async fn add_watched_dir_handler(
2856 State(state): State<AppState>,
2857 Form(form): Form<WatchedDirForm>,
2858) -> impl IntoResponse {
2859 if state.server_mode {
2860 return StatusCode::NOT_FOUND.into_response();
2861 }
2862 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2863 strip_unc_prefix(p)
2864 } else {
2865 let dest = format!(
2866 "{}?error=Folder+not+found+or+path+is+invalid.",
2867 safe_redirect(&form.redirect_to)
2868 );
2869 return axum::response::Redirect::to(&dest).into_response();
2870 };
2871 if !folder.is_dir() {
2872 let dest = format!(
2873 "{}?error=Selected+path+is+not+a+directory.",
2874 safe_redirect(&form.redirect_to)
2875 );
2876 return axum::response::Redirect::to(&dest).into_response();
2877 }
2878
2879 {
2881 let mut wd = state.watched_dirs.lock().await;
2882 wd.add(folder.clone());
2883 let _ = wd.save(&state.watched_dirs_path);
2884 }
2885
2886 let linked = {
2888 let mut reg = state.registry.lock().await;
2889 let n = scan_folder_into_registry(&folder, &mut reg);
2890 if n > 0 {
2891 let _ = reg.save(&state.registry_path);
2892 }
2893 n
2894 };
2895
2896 let dest = if linked > 0 {
2897 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2898 } else {
2899 format!(
2900 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2901 safe_redirect(&form.redirect_to)
2902 )
2903 };
2904 axum::response::Redirect::to(&dest).into_response()
2905}
2906
2907async fn remove_watched_dir_handler(
2908 State(state): State<AppState>,
2909 Form(form): Form<WatchedDirForm>,
2910) -> impl IntoResponse {
2911 if state.server_mode {
2912 return StatusCode::NOT_FOUND.into_response();
2913 }
2914 let folder = PathBuf::from(&form.folder_path);
2915 {
2916 let mut wd = state.watched_dirs.lock().await;
2917 wd.remove(&folder);
2918 let _ = wd.save(&state.watched_dirs_path);
2919 }
2920 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2921}
2922
2923async fn refresh_watched_dirs_handler(
2924 State(state): State<AppState>,
2925 Form(form): Form<WatchedDirRefreshForm>,
2926) -> impl IntoResponse {
2927 if state.server_mode {
2928 return StatusCode::NOT_FOUND.into_response();
2929 }
2930 let dirs: Vec<PathBuf> = {
2931 let wd = state.watched_dirs.lock().await;
2932 wd.dirs.clone()
2933 };
2934 let mut total = 0usize;
2935 {
2936 let mut reg = state.registry.lock().await;
2937 for dir in &dirs {
2938 if dir.is_dir() {
2939 total += scan_folder_into_registry(dir, &mut reg);
2940 }
2941 }
2942 if total > 0 {
2943 let _ = reg.save(&state.registry_path);
2944 }
2945 }
2946 let dest = if total > 0 {
2947 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2948 } else {
2949 safe_redirect(&form.redirect_to).to_owned()
2950 };
2951 axum::response::Redirect::to(&dest).into_response()
2952}
2953
2954#[derive(Debug, Deserialize)]
2955struct OpenPathQuery {
2956 path: Option<String>,
2957}
2958
2959async fn open_path_handler(
2960 State(state): State<AppState>,
2961 Query(query): Query<OpenPathQuery>,
2962) -> impl IntoResponse {
2963 if state.server_mode {
2964 return Json(serde_json::json!({
2965 "server_mode_disabled": true,
2966 "message": "Opening a path in the file manager is only available in local desktop mode."
2967 }))
2968 .into_response();
2969 }
2970 let raw = match query.path.as_deref() {
2971 Some(p) if !p.is_empty() => p,
2972 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2973 };
2974
2975 let target = match tokio::fs::canonicalize(raw).await {
2979 Ok(canonical) if canonical.is_file() => match canonical.parent() {
2980 Some(p) => p.to_path_buf(),
2981 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2982 },
2983 Ok(canonical) if canonical.is_dir() => canonical,
2984 Ok(_) => {
2985 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2986 }
2987 Err(_) => {
2988 let mut ancestor = std::path::Path::new(raw);
2990 loop {
2991 match ancestor.parent() {
2992 Some(p) => {
2993 ancestor = p;
2994 if ancestor.is_dir() {
2995 break;
2996 }
2997 }
2998 None => {
2999 return (StatusCode::BAD_REQUEST, "no existing ancestor found")
3000 .into_response();
3001 }
3002 }
3003 }
3004 ancestor.to_path_buf()
3005 }
3006 };
3007
3008 #[cfg(target_os = "windows")]
3009 {
3010 let ps_cmd = "Add-Type -TypeDefinition \
3014 'using System;using System.Runtime.InteropServices;\
3015 public class WF{\
3016 [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
3017 [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
3018 }'; \
3019 $p=$env:SLOC_OPEN_PATH; \
3020 $sh=New-Object -ComObject Shell.Application; \
3021 $sh.Open($p); \
3022 Start-Sleep -Milliseconds 600; \
3023 foreach($w in $sh.Windows()){ \
3024 try{ \
3025 if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
3026 [System.IO.Path]::GetFullPath($p)){ \
3027 [WF]::ShowWindow($w.HWND,3); \
3028 [WF]::SetForegroundWindow($w.HWND); \
3029 break \
3030 } \
3031 }catch{} \
3032 }";
3033 let _ = std::process::Command::new("powershell")
3034 .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
3035 .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
3036 .stdout(Stdio::null())
3037 .stderr(Stdio::null())
3038 .spawn();
3039 }
3040 #[cfg(target_os = "macos")]
3041 let _ = std::process::Command::new("open")
3042 .arg(&target)
3043 .stdout(Stdio::null())
3044 .stderr(Stdio::null())
3045 .spawn();
3046 #[cfg(target_os = "linux")]
3047 let _ = std::process::Command::new("xdg-open")
3048 .arg(&target)
3049 .stdout(Stdio::null())
3050 .stderr(Stdio::null())
3051 .spawn();
3052
3053 (StatusCode::OK, "ok").into_response()
3054}
3055
3056async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3057 let (content_type, bytes): (&'static str, &'static [u8]) =
3058 match (folder.as_str(), file.as_str()) {
3059 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3060 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3061 ("icons", "c.png") => ("image/png", IMG_ICON_C),
3062 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3063 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3064 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3065 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3066 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3067 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3068 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3069 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3070 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3071 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3072 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3073 ("icons", "r.png") => ("image/png", IMG_ICON_R),
3074 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3075 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3076 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3077 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3078 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3079 _ => return StatusCode::NOT_FOUND.into_response(),
3080 };
3081 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3082}
3083
3084async fn preview_handler(
3085 State(state): State<AppState>,
3086 Query(query): Query<PreviewQuery>,
3087) -> impl IntoResponse {
3088 let raw_path = query
3089 .path
3090 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3091 let resolved = resolve_input_path(&raw_path);
3092
3093 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3097 return Html(
3098 r#"<div class="preview-error">Sample directory not available on this server.
3099 Enter a path to a project directory or upload files using Browse.</div>"#
3100 .to_string(),
3101 );
3102 }
3103
3104 if state.server_mode {
3105 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3106 if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3108 let config = &state.base_config;
3109 if config.discovery.allowed_scan_roots.is_empty() {
3110 return Html(
3111 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3112 );
3113 }
3114 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3115 fs::canonicalize(root)
3116 .ok()
3117 .is_some_and(|r| canonical.starts_with(&r))
3118 });
3119 if !allowed {
3120 return Html(
3121 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3122 );
3123 }
3124 }
3125 }
3126
3127 let include_patterns = split_patterns(query.include_globs.as_deref());
3128 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3129
3130 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3131 Ok(html) => Html(html),
3132 Err(err) => Html(format!(
3133 r#"<div class="preview-error">Preview failed: {}</div>"#,
3134 escape_html(&err.to_string())
3135 )),
3136 }
3137}
3138
3139#[derive(Debug, Deserialize, Default)]
3140struct SuggestCoverageQuery {
3141 path: Option<String>,
3142}
3143
3144#[derive(Serialize)]
3145struct SuggestCoverageResponse {
3146 found: Option<String>,
3147 tool: Option<&'static str>,
3148 hint: Option<&'static str>,
3149}
3150
3151async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3152 const CANDIDATES: &[&str] = &[
3153 "coverage/lcov.info",
3155 "lcov.info",
3156 "target/llvm-cov/lcov.info",
3157 "target/coverage/lcov.info",
3158 "target/debug/coverage/lcov.info",
3159 "coverage/coverage.lcov",
3160 "build/coverage/lcov.info",
3161 "reports/lcov.info",
3162 "coverage.xml",
3164 "coverage/coverage.xml",
3165 "target/site/cobertura/coverage.xml",
3166 "build/reports/coverage/coverage.xml",
3167 "target/site/jacoco/jacoco.xml",
3169 "build/reports/jacoco/test/jacocoTestReport.xml",
3170 "build/reports/jacoco/jacocoTestReport.xml",
3171 "build/jacoco/jacoco.xml",
3172 ];
3173 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3174 let found = CANDIDATES
3175 .iter()
3176 .map(|rel| root.join(rel))
3177 .find(|p| p.is_file())
3178 .map(|p| display_path(&p));
3179
3180 let (tool, hint) = detect_coverage_tool(&root);
3181 Json(SuggestCoverageResponse { found, tool, hint })
3182}
3183
3184fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3187 if root.join("Cargo.toml").is_file() {
3188 return (
3189 Some("cargo-llvm-cov"),
3190 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3191 );
3192 }
3193 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3194 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3195 }
3196 if root.join("pom.xml").is_file() {
3197 return (Some("jacoco"), Some("mvn test jacoco:report"));
3198 }
3199 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3200 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3201 }
3202 (None, None)
3203}
3204
3205#[allow(clippy::result_large_err)]
3207fn validate_server_scan_path(
3208 config: &sloc_config::AppConfig,
3209 resolved_path: &Path,
3210 csp_nonce: &str,
3211) -> Result<(), Response> {
3212 if config.discovery.allowed_scan_roots.is_empty() {
3213 let template = ErrorTemplate {
3214 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3215 Set allowed_scan_roots in the server config to permit scanning."
3216 .to_string(),
3217 last_report_url: None,
3218 last_report_label: None,
3219 run_id: None,
3220 error_code: Some(403),
3221 csp_nonce: csp_nonce.to_owned(),
3222 version: env!("CARGO_PKG_VERSION"),
3223 };
3224 return Err((
3225 StatusCode::FORBIDDEN,
3226 Html(
3227 template
3228 .render()
3229 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3230 ),
3231 )
3232 .into_response());
3233 }
3234 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3235 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3236 fs::canonicalize(root)
3237 .ok()
3238 .is_some_and(|r| canonical.starts_with(&r))
3239 });
3240 if !allowed {
3241 tracing::warn!(event = "path_rejected", path = %canonical.display(),
3242 "Scan path not in allowed_scan_roots");
3243 let template = ErrorTemplate {
3244 message: "The requested path is not within an allowed scan directory.".to_string(),
3245 last_report_url: None,
3246 last_report_label: None,
3247 run_id: None,
3248 error_code: Some(403),
3249 csp_nonce: csp_nonce.to_owned(),
3250 version: env!("CARGO_PKG_VERSION"),
3251 };
3252 return Err((
3253 StatusCode::FORBIDDEN,
3254 Html(
3255 template
3256 .render()
3257 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3258 ),
3259 )
3260 .into_response());
3261 }
3262 Ok(())
3263}
3264
3265fn apply_output_dir_exclusions(
3267 config: &mut sloc_config::AppConfig,
3268 project_path: &str,
3269 raw_output_dir: &str,
3270) {
3271 let project_root = resolve_input_path(project_path);
3272 let raw_out = raw_output_dir.trim();
3273 let resolved_out = if raw_out.is_empty() {
3274 project_root.join("sloc")
3275 } else if Path::new(raw_out).is_absolute() {
3276 PathBuf::from(raw_out)
3277 } else {
3278 workspace_root().join(raw_out)
3279 };
3280 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3281 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3282 let dir = first.to_string();
3283 if !config.discovery.excluded_directories.contains(&dir) {
3284 config.discovery.excluded_directories.push(dir);
3285 }
3286 }
3287 }
3288 if !config
3289 .discovery
3290 .excluded_directories
3291 .iter()
3292 .any(|d| d == "sloc")
3293 {
3294 config
3295 .discovery
3296 .excluded_directories
3297 .push("sloc".to_string());
3298 }
3299}
3300
3301const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3303 ScanSummarySnapshot {
3304 files_analyzed: run.summary_totals.files_analyzed,
3305 files_skipped: run.summary_totals.files_skipped,
3306 total_physical_lines: run.summary_totals.total_physical_lines,
3307 code_lines: run.summary_totals.code_lines,
3308 comment_lines: run.summary_totals.comment_lines,
3309 blank_lines: run.summary_totals.blank_lines,
3310 functions: run.summary_totals.functions,
3311 classes: run.summary_totals.classes,
3312 variables: run.summary_totals.variables,
3313 imports: run.summary_totals.imports,
3314 test_count: run.summary_totals.test_count,
3315 coverage_lines_found: run.summary_totals.coverage_lines_found,
3316 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3317 coverage_functions_found: run.summary_totals.coverage_functions_found,
3318 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3319 coverage_branches_found: run.summary_totals.coverage_branches_found,
3320 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3321 }
3322}
3323
3324pub(crate) fn build_run_registry_entry(
3326 run: &AnalysisRun,
3327 run_id: &str,
3328 project_label: &str,
3329 artifacts: &RunArtifacts,
3330) -> RegistryEntry {
3331 RegistryEntry {
3332 run_id: run_id.to_owned(),
3333 timestamp_utc: run.tool.timestamp_utc,
3334 project_label: project_label.to_owned(),
3335 input_roots: run.input_roots.clone(),
3336 json_path: artifacts.json_path.clone(),
3337 html_path: artifacts.html_path.clone(),
3338 pdf_path: artifacts.pdf_path.clone(),
3339 csv_path: artifacts.csv_path.clone(),
3340 xlsx_path: artifacts.xlsx_path.clone(),
3341 summary: summary_snapshot_from_run(run),
3342 git_branch: run.git_branch.clone(),
3343 git_commit: run.git_commit_short.clone(),
3344 git_author: run.git_commit_author.clone(),
3345 git_tags: run.git_tags.clone(),
3346 git_nearest_tag: run.git_nearest_tag.clone(),
3347 git_commit_date: run.git_commit_date.clone(),
3348 }
3349}
3350
3351fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3353 if let Some(policy) = form.mixed_line_policy {
3354 config.analysis.mixed_line_policy = policy;
3355 }
3356 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3357 config.analysis.generated_file_detection =
3358 form.generated_file_detection.as_deref() != Some("disabled");
3359 config.analysis.minified_file_detection =
3360 form.minified_file_detection.as_deref() != Some("disabled");
3361 config.analysis.vendor_directory_detection =
3362 form.vendor_directory_detection.as_deref() != Some("disabled");
3363 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3364 if let Some(binary_behavior) = form.binary_file_behavior {
3365 config.analysis.binary_file_behavior = binary_behavior;
3366 }
3367 if let Some(report_title) = form.report_title.as_deref() {
3368 let trimmed = report_title.trim();
3369 if !trimmed.is_empty() {
3370 config.reporting.report_title = trimmed.to_string();
3371 }
3372 }
3373 if let Some(hf) = form.report_header_footer.as_deref() {
3374 let trimmed = hf.trim();
3375 config.reporting.report_header_footer = if trimmed.is_empty() {
3376 None
3377 } else {
3378 Some(trimmed.to_string())
3379 };
3380 }
3381 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3382 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3383 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3384 if let Some(policy) = form.continuation_line_policy {
3385 config.analysis.continuation_line_policy = policy;
3386 }
3387 if let Some(policy) = form.blank_in_block_comment_policy {
3388 config.analysis.blank_in_block_comment_policy = policy;
3389 }
3390 config.analysis.count_compiler_directives =
3391 form.count_compiler_directives.as_deref() != Some("disabled");
3392 if let Some(cov) = &form.coverage_file {
3393 let trimmed = cov.trim();
3394 if !trimmed.is_empty() {
3395 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3396 }
3397 }
3398}
3399
3400fn spawn_pdf_background(
3404 pending_pdf: PendingPdf,
3405 run_id: String,
3406 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3407) {
3408 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3409 tokio::spawn(async move {
3410 let result = tokio::task::spawn_blocking(move || {
3411 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3412 if cleanup_src {
3413 let _ = fs::remove_file(&pdf_src);
3414 }
3415 r
3416 })
3417 .await;
3418 let failed = match result {
3419 Ok(Ok(())) => false,
3420 Ok(Err(err)) => {
3421 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3422 true
3423 }
3424 Err(err) => {
3425 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3426 true
3427 }
3428 };
3429 if failed {
3430 let mut map = artifacts.lock().await;
3431 if let Some(entry) = map.get_mut(&run_id) {
3432 entry.pdf_path = None;
3433 }
3434 }
3435 });
3436 }
3437}
3438
3439fn spawn_native_pdf_background(
3443 json_path: PathBuf,
3444 pdf_dest: PathBuf,
3445 run_id: String,
3446 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3447) {
3448 tokio::spawn(async move {
3449 let result = tokio::task::spawn_blocking(move || {
3450 let run = sloc_core::read_json(&json_path)?;
3451 write_pdf_from_run(&run, &pdf_dest)
3452 })
3453 .await;
3454 let failed = match result {
3455 Ok(Ok(())) => false,
3456 Ok(Err(err)) => {
3457 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
3458 true
3459 }
3460 Err(err) => {
3461 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
3462 true
3463 }
3464 };
3465 if failed {
3466 let mut map = artifacts.lock().await;
3467 if let Some(entry) = map.get_mut(&run_id) {
3468 entry.pdf_path = None;
3469 }
3470 }
3471 });
3472}
3473
3474fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3476 cmp.file_deltas
3477 .iter()
3478 .map(|f| match f.status {
3479 FileChangeStatus::Added => f.current_code,
3480 FileChangeStatus::Modified => f.code_delta.max(0),
3481 _ => 0,
3482 })
3483 .sum()
3484}
3485
3486fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3488 cmp.file_deltas
3489 .iter()
3490 .map(|f| match f.status {
3491 FileChangeStatus::Removed => f.baseline_code,
3492 FileChangeStatus::Modified => (-f.code_delta).max(0),
3493 _ => 0,
3494 })
3495 .sum()
3496}
3497
3498fn build_submodule_row(
3500 s: &sloc_core::SubmoduleSummary,
3501 run: &AnalysisRun,
3502 run_id: &str,
3503 run_dir: &Path,
3504 generate_html: bool,
3505) -> SubmoduleRow {
3506 let safe = sanitize_project_label(&s.name);
3507 let artifact_key = format!("sub_{safe}");
3508 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
3509 let parent_path = run
3510 .input_roots
3511 .first()
3512 .map_or("", std::string::String::as_str);
3513 let sub_run = build_sub_run(run, s, parent_path);
3514 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
3515 let path = run_dir.join(format!("{artifact_key}.html"));
3516 if fs::write(&path, sub_html.as_bytes()).is_ok() {
3517 Some(format!("/runs/{artifact_key}/{run_id}"))
3518 } else {
3519 None
3520 }
3521 })
3522 } else {
3523 None
3524 };
3525 SubmoduleRow {
3526 name: s.name.clone(),
3527 relative_path: s.relative_path.clone(),
3528 files_analyzed: s.files_analyzed,
3529 code_lines: s.code_lines,
3530 comment_lines: s.comment_lines,
3531 blank_lines: s.blank_lines,
3532 total_physical_lines: s.total_physical_lines,
3533 html_url,
3534 }
3535}
3536
3537#[allow(clippy::similar_names)]
3540#[allow(clippy::significant_drop_tightening)] async fn analyze_handler(
3542 State(state): State<AppState>,
3543 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3544 Form(form): Form<AnalyzeForm>,
3545) -> impl IntoResponse {
3546 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
3547 let template = ErrorTemplate {
3548 message: format!(
3549 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
3550 Please wait a moment and try again."
3551 ),
3552 last_report_url: None,
3553 last_report_label: None,
3554 run_id: None,
3555 error_code: Some(503),
3556 csp_nonce: csp_nonce.clone(),
3557 version: env!("CARGO_PKG_VERSION"),
3558 };
3559 return (
3560 StatusCode::SERVICE_UNAVAILABLE,
3561 Html(
3562 template
3563 .render()
3564 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
3565 ),
3566 )
3567 .into_response();
3568 };
3569
3570 let mut config = state.base_config.clone();
3571
3572 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
3573 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
3574 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
3575
3576 if !is_git_mode {
3577 let resolved_path = resolve_input_path(&form.path);
3578 if state.server_mode
3579 && !is_upload_tmp_path(&resolved_path)
3580 && !is_sample_path(&resolved_path)
3581 {
3582 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3583 return resp;
3584 }
3585 }
3586 config.discovery.root_paths = vec![resolved_path];
3587 }
3588
3589 apply_form_to_config(&mut config, &form);
3590 apply_output_dir_exclusions(
3591 &mut config,
3592 &form.path,
3593 form.output_dir.as_deref().unwrap_or(""),
3594 );
3595
3596 let wait_id = uuid::Uuid::new_v4().to_string();
3598 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3599
3600 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3602 let task_cancel = Arc::clone(&cancel_token);
3603
3604 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
3606 let task_phase = Arc::clone(&phase);
3607
3608 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3609 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3610 let task_files_done = Arc::clone(&files_done);
3611 let task_files_total = Arc::clone(&files_total);
3612
3613 {
3616 let mut runs = state.async_runs.lock().await;
3617 runs.insert(
3618 wait_id.clone(),
3619 AsyncRunState::Running {
3620 started_at: std::time::Instant::now(),
3621 cancel_token,
3622 phase,
3623 files_done,
3624 files_total,
3625 },
3626 );
3627 }
3628
3629 let task = AnalysisTask {
3630 sem_permit,
3631 state: state.clone(),
3632 wait_id: wait_id.clone(),
3633 config,
3634 cancel: task_cancel,
3635 phase: task_phase,
3636 files_done: task_files_done,
3637 files_total: task_files_total,
3638 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
3639 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
3640 generate_html: form.generate_html.is_some(),
3641 generate_pdf: form.generate_pdf.is_some(),
3642 project_path: form.path.clone(),
3643 output_dir: if state.server_mode {
3647 None
3648 } else {
3649 form.output_dir.clone()
3650 },
3651 clones_dir: state.git_clones_dir.clone(),
3652 };
3653
3654 tokio::spawn(run_analysis_task(task));
3655
3656 let template = ScanWaitTemplate {
3657 version: env!("CARGO_PKG_VERSION"),
3658 wait_id_json,
3659 project_path: form.path.clone(),
3660 csp_nonce,
3661 };
3662 let html = template
3663 .render()
3664 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3665 let mut response = Html(html).into_response();
3666 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3667 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3668 response.headers_mut().insert(name, val);
3669 }
3670 }
3671 response
3672}
3673
3674struct AnalysisTask {
3675 sem_permit: tokio::sync::OwnedSemaphorePermit,
3676 state: AppState,
3677 wait_id: String,
3678 config: AppConfig,
3679 cancel: Arc<std::sync::atomic::AtomicBool>,
3680 phase: Arc<std::sync::Mutex<String>>,
3681 files_done: Arc<std::sync::atomic::AtomicUsize>,
3682 files_total: Arc<std::sync::atomic::AtomicUsize>,
3683 git_repo: Option<String>,
3684 git_ref: Option<String>,
3685 generate_html: bool,
3686 generate_pdf: bool,
3687 project_path: String,
3688 output_dir: Option<String>,
3689 clones_dir: PathBuf,
3690}
3691
3692#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
3694 let _permit = task.sem_permit;
3695
3696 let cancel_sb = Arc::clone(&task.cancel);
3697 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
3698 let clones_dir_sb = task.clones_dir;
3699 let upload_staging_root = task
3701 .config
3702 .discovery
3703 .root_paths
3704 .first()
3705 .filter(|p| is_upload_tmp_path(p))
3706 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
3707 .map(PathBuf::from);
3708 let config_sb = task.config;
3709 let progress_sb = sloc_core::ProgressCounters {
3710 files_done: Arc::clone(&task.files_done),
3711 files_total: Arc::clone(&task.files_total),
3712 };
3713 if let Ok(mut p) = task.phase.lock() {
3714 *p = "Scanning files".to_string();
3715 }
3716 let analysis_result = tokio::task::spawn_blocking(move || {
3717 run_analysis_blocking(
3718 config_sb,
3719 git_repo_sb,
3720 git_ref_sb,
3721 clones_dir_sb,
3722 cancel_sb,
3723 Some(progress_sb),
3724 )
3725 })
3726 .await
3727 .map_err(|err| anyhow::anyhow!(err.to_string()))
3728 .and_then(|result| result);
3729
3730 if let Ok(mut p) = task.phase.lock() {
3731 *p = "Writing reports".to_string();
3732 }
3733
3734 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
3736 let mut runs = task.state.async_runs.lock().await;
3737 if matches!(
3739 runs.get(&task.wait_id),
3740 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3741 ) {
3742 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3743 }
3744 drop(runs);
3745 return;
3746 }
3747
3748 let (run, report_html) = match analysis_result {
3749 Ok(v) => v,
3750 Err(err) => {
3751 if err.to_string().contains("analysis cancelled") {
3753 let mut runs = task.state.async_runs.lock().await;
3754 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3755 drop(runs);
3756 return;
3757 }
3758 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3759 let mut runs = task.state.async_runs.lock().await;
3760 runs.insert(
3761 task.wait_id.clone(),
3762 AsyncRunState::Failed {
3763 message: "Analysis failed. Check that the path exists and is readable."
3764 .to_string(),
3765 },
3766 );
3767 drop(runs);
3768 return;
3769 }
3770 };
3771
3772 let run_id = run.tool.run_id.clone();
3773 tracing::info!(event = "scan_complete", run_id = %run_id,
3774 path = %task.project_path, files = run.summary_totals.files_analyzed,
3775 "Analysis finished");
3776
3777 let prev_entry: Option<RegistryEntry> = {
3778 let reg = task.state.registry.lock().await;
3779 reg.entries_for_roots(&run.input_roots)
3780 .into_iter()
3781 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3782 .cloned()
3783 };
3784
3785 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3786 prev.json_path
3787 .as_ref()
3788 .and_then(|p| read_json(p).ok())
3789 .map(|prev_run| compute_delta(&prev_run, &run))
3790 });
3791 let prev_scan_count: usize = {
3792 let reg = task.state.registry.lock().await;
3793 reg.entries_for_roots(&run.input_roots)
3794 .iter()
3795 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3796 .count()
3797 };
3798
3799 let output_root = resolve_output_root(task.output_dir.as_deref());
3800 let project_label = derive_project_label(
3801 task.git_repo.as_deref(),
3802 task.git_ref.as_deref(),
3803 &task.project_path,
3804 );
3805 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3806 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
3807
3808 let result_context = RunResultContext {
3809 prev_entry: prev_entry.clone(),
3810 prev_scan_count,
3811 project_path: task.project_path.clone(),
3812 };
3813
3814 let artifact_result = persist_run_artifacts(
3815 &run,
3816 &report_html,
3817 &run_dir,
3818 true,
3819 task.generate_html,
3820 task.generate_pdf,
3821 &run.effective_configuration.reporting.report_title,
3822 &file_stem,
3823 result_context,
3824 );
3825
3826 let (artifacts, pending_pdf) = match artifact_result {
3827 Ok(v) => v,
3828 Err(err) => {
3829 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3830 let mut runs = task.state.async_runs.lock().await;
3831 runs.insert(
3832 task.wait_id.clone(),
3833 AsyncRunState::Failed {
3834 message: "Failed to save report artifacts. Check available disk space."
3835 .to_string(),
3836 },
3837 );
3838 drop(runs);
3839 return;
3840 }
3841 };
3842
3843 {
3844 let mut map = task.state.artifacts.lock().await;
3845 map.insert(run_id.clone(), artifacts.clone());
3846 }
3847
3848 {
3849 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3850 let mut reg = task.state.registry.lock().await;
3851 reg.add_entry(entry);
3852 let _ = reg.save(&task.state.registry_path);
3853 }
3854
3855 if let Some(ref cfg_path) = artifacts.scan_config_path {
3856 save_scan_config_json(
3857 cfg_path,
3858 &run,
3859 &task.project_path,
3860 task.output_dir.as_deref(),
3861 task.generate_html,
3862 task.generate_pdf,
3863 );
3864 }
3865
3866 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
3867
3868 prom_runs_total().inc();
3869
3870 let mut runs = task.state.async_runs.lock().await;
3872 runs.insert(
3873 task.wait_id.clone(),
3874 AsyncRunState::Complete {
3875 run_id: run_id.clone(),
3876 },
3877 );
3878 drop(runs);
3879
3880 if let Some(staging) = upload_staging_root {
3883 let _ = tokio::fs::remove_dir_all(staging).await;
3884 }
3885
3886 let _ = scan_delta;
3887}
3888
3889fn save_scan_config_json(
3890 cfg_path: &std::path::Path,
3891 run: &sloc_core::AnalysisRun,
3892 project_path: &str,
3893 output_dir: Option<&str>,
3894 generate_html: bool,
3895 generate_pdf: bool,
3896) {
3897 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3898 .ok()
3899 .and_then(|v| v.as_str().map(String::from))
3900 .unwrap_or_else(|| "code_only".to_string());
3901 let behavior_str =
3902 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3903 .ok()
3904 .and_then(|v| v.as_str().map(String::from))
3905 .unwrap_or_else(|| "skip".to_string());
3906 let scan_cfg = ScanConfig {
3907 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3908 path: project_path.to_string(),
3909 include_globs: run
3910 .effective_configuration
3911 .discovery
3912 .include_globs
3913 .join("\n"),
3914 exclude_globs: run
3915 .effective_configuration
3916 .discovery
3917 .exclude_globs
3918 .join("\n"),
3919 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3920 mixed_line_policy: policy_str,
3921 python_docstrings_as_comments: run
3922 .effective_configuration
3923 .analysis
3924 .python_docstrings_as_comments,
3925 generated_file_detection: run
3926 .effective_configuration
3927 .analysis
3928 .generated_file_detection,
3929 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
3930 vendor_directory_detection: run
3931 .effective_configuration
3932 .analysis
3933 .vendor_directory_detection,
3934 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3935 binary_file_behavior: behavior_str,
3936 output_dir: output_dir.unwrap_or("").to_string(),
3937 report_title: run.effective_configuration.reporting.report_title.clone(),
3938 generate_html,
3939 generate_pdf,
3940 };
3941 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3942 let _ = std::fs::write(cfg_path, json);
3943 }
3944}
3945
3946#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
3948 mut config: AppConfig,
3949 git_repo: Option<String>,
3950 git_ref: Option<String>,
3951 clones_dir: PathBuf,
3952 cancel: Arc<std::sync::atomic::AtomicBool>,
3953 progress: Option<sloc_core::ProgressCounters>,
3954) -> Result<(sloc_core::AnalysisRun, String)> {
3955 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
3956 let dest = git_clone_dest(&repo, &clones_dir);
3957 sloc_git::clone_or_fetch(&repo, &dest)?;
3958 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3959 sloc_git::create_worktree(&dest, &refname, &wt)?;
3960 config.discovery.root_paths = vec![wt.clone()];
3961 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
3962 let _ = sloc_git::destroy_worktree(&dest, &wt);
3963 let mut run = run?;
3964 if run.git_branch.is_none() {
3965 run.git_branch = Some(refname);
3966 }
3967 let html = render_html(&run)?;
3968 return Ok((run, html));
3969 }
3970 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref())?;
3971 let html = render_html(&run)?;
3972 Ok((run, html))
3973}
3974
3975fn derive_project_label(
3976 git_repo: Option<&str>,
3977 git_ref: Option<&str>,
3978 fallback_path: &str,
3979) -> String {
3980 match (
3981 git_repo.filter(|s| !s.is_empty()),
3982 git_ref.filter(|s| !s.is_empty()),
3983 ) {
3984 (Some(repo), Some(refname)) => {
3985 let repo_name = repo
3986 .trim_end_matches('/')
3987 .trim_end_matches(".git")
3988 .rsplit('/')
3989 .next()
3990 .unwrap_or("repo");
3991 sanitize_project_label(&format!("{repo_name}_{refname}"))
3992 }
3993 _ => sanitize_project_label(fallback_path),
3994 }
3995}
3996
3997fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
3998 let commit = commit_short.unwrap_or("").trim();
3999 if commit.is_empty() {
4000 project_label.to_string()
4001 } else {
4002 format!("{project_label}_{commit}")
4003 }
4004}
4005
4006#[derive(Serialize)]
4009#[serde(tag = "state", rename_all = "snake_case")]
4010enum AsyncRunStatusResponse {
4011 Running {
4012 elapsed_secs: u64,
4013 phase: String,
4014 files_done: u64,
4015 files_total: u64,
4016 },
4017 Complete {
4018 run_id: String,
4019 },
4020 Failed {
4021 message: String,
4022 },
4023 Cancelled,
4024}
4025
4026async fn async_run_status_handler(
4027 State(state): State<AppState>,
4028 AxumPath(wait_id): AxumPath<String>,
4029) -> Response {
4030 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4032 return error::bad_request("invalid wait_id");
4033 }
4034 let run_state = {
4035 let runs = state.async_runs.lock().await;
4036 runs.get(&wait_id).cloned()
4037 };
4038 match run_state {
4039 None => error::not_found("run not found"),
4040 Some(AsyncRunState::Running {
4041 started_at,
4042 phase,
4043 files_done,
4044 files_total,
4045 ..
4046 }) => {
4047 if started_at.elapsed() > std::time::Duration::from_hours(2) {
4049 let mut runs = state.async_runs.lock().await;
4050 runs.insert(
4051 wait_id,
4052 AsyncRunState::Failed {
4053 message: "Analysis timed out after 2 hours.".to_string(),
4054 },
4055 );
4056 drop(runs);
4057 return Json(AsyncRunStatusResponse::Failed {
4058 message: "Analysis timed out after 2 hours.".to_string(),
4059 })
4060 .into_response();
4061 }
4062 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4063 Json(AsyncRunStatusResponse::Running {
4064 elapsed_secs: started_at.elapsed().as_secs(),
4065 phase: phase_str,
4066 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4067 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4068 })
4069 .into_response()
4070 }
4071 Some(AsyncRunState::Complete { run_id }) => {
4072 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4073 }
4074 Some(AsyncRunState::Failed { message }) => {
4075 Json(AsyncRunStatusResponse::Failed { message }).into_response()
4076 }
4077 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4078 }
4079}
4080
4081async fn cancel_run_handler(
4082 State(state): State<AppState>,
4083 AxumPath(wait_id): AxumPath<String>,
4084) -> Response {
4085 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4086 return error::bad_request("invalid wait_id");
4087 }
4088 let mut runs = state.async_runs.lock().await;
4089 let resp = match runs.get(&wait_id) {
4090 Some(AsyncRunState::Running { cancel_token, .. }) => {
4091 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4092 runs.insert(wait_id, AsyncRunState::Cancelled);
4093 StatusCode::OK.into_response()
4094 }
4095 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4096 _ => error::not_found("run not found"),
4097 };
4098 drop(runs);
4099 resp
4100}
4101
4102async fn async_run_result_handler(
4103 State(state): State<AppState>,
4104 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4105 AxumPath(run_id): AxumPath<String>,
4106) -> Response {
4107 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4108 return StatusCode::BAD_REQUEST.into_response();
4109 }
4110
4111 let artifacts = {
4112 let map = state.artifacts.lock().await;
4113 map.get(&run_id).cloned()
4114 };
4115 let artifacts = if let Some(a) = artifacts {
4116 a
4117 } else {
4118 let reg = state.registry.lock().await;
4119 if let Some(entry) = reg.find_by_run_id(&run_id) {
4120 recover_artifacts_from_registry(entry)
4121 } else {
4122 let html = ErrorTemplate {
4123 message: format!(
4124 "Report not found. Run ID {} is not in the scan history.",
4125 &run_id[..run_id.len().min(8)]
4126 ),
4127 last_report_url: Some("/view-reports".to_string()),
4128 last_report_label: Some("View Reports".to_string()),
4129 run_id: Some(run_id.clone()),
4130 error_code: Some(404),
4131 csp_nonce: csp_nonce.clone(),
4132 version: env!("CARGO_PKG_VERSION"),
4133 }
4134 .render()
4135 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4136 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4137 }
4138 };
4139
4140 let json_path = if let Some(p) = &artifacts.json_path {
4141 p.clone()
4142 } else {
4143 let html = ErrorTemplate {
4144 message: "JSON result was not saved for this run.".to_string(),
4145 last_report_url: Some("/view-reports".to_string()),
4146 last_report_label: Some("View Reports".to_string()),
4147 run_id: Some(run_id.clone()),
4148 error_code: Some(404),
4149 csp_nonce: csp_nonce.clone(),
4150 version: env!("CARGO_PKG_VERSION"),
4151 }
4152 .render()
4153 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4154 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4155 };
4156
4157 let Ok(run) = read_json(&json_path) else {
4158 let folder_hint = json_path
4159 .parent()
4160 .map(|p| p.display().to_string())
4161 .unwrap_or_default();
4162 let redirect_url = format!("/runs/result/{run_id}");
4163 return missing_scan_relocate_response(
4164 &format!(
4165 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
4166 deleted. Browse to the folder containing your scan output to reconnect it.",
4167 json_path.display()
4168 ),
4169 &run_id,
4170 &folder_hint,
4171 &redirect_url,
4172 state.server_mode,
4173 &csp_nonce,
4174 );
4175 };
4176
4177 let confluence_configured = {
4178 let store = state.confluence.lock().await;
4179 store.is_configured()
4180 };
4181
4182 render_result_page(
4183 &run,
4184 &artifacts,
4185 &run_id,
4186 &csp_nonce,
4187 confluence_configured,
4188 state.server_mode,
4189 )
4190}
4191
4192#[allow(clippy::too_many_lines)]
4193#[allow(clippy::similar_names)] fn render_result_page(
4195 run: &AnalysisRun,
4196 artifacts: &RunArtifacts,
4197 run_id: &str,
4198 csp_nonce: &str,
4199 confluence_configured: bool,
4200 server_mode: bool,
4201) -> Response {
4202 let ctx = &artifacts.result_context;
4203 let prev_entry = &ctx.prev_entry;
4204 let prev_scan_count = ctx.prev_scan_count;
4205 let project_path = &ctx.project_path;
4206
4207 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4208 prev.json_path
4209 .as_ref()
4210 .and_then(|p| read_json(p).ok())
4211 .map(|prev_run| compute_delta(&prev_run, run))
4212 });
4213
4214 let files_analyzed = run.per_file_records.len() as u64;
4215 let files_skipped = run.skipped_file_records.len() as u64;
4216 let physical_lines = run
4217 .totals_by_language
4218 .iter()
4219 .map(|r| r.total_physical_lines)
4220 .sum::<u64>();
4221 let code_lines = run
4222 .totals_by_language
4223 .iter()
4224 .map(|r| r.code_lines)
4225 .sum::<u64>();
4226 let comment_lines = run
4227 .totals_by_language
4228 .iter()
4229 .map(|r| r.comment_lines)
4230 .sum::<u64>();
4231 let blank_lines = run
4232 .totals_by_language
4233 .iter()
4234 .map(|r| r.blank_lines)
4235 .sum::<u64>();
4236 let mixed_lines = run
4237 .totals_by_language
4238 .iter()
4239 .map(|r| r.mixed_lines_separate)
4240 .sum::<u64>();
4241 let functions = run
4242 .totals_by_language
4243 .iter()
4244 .map(|r| r.functions)
4245 .sum::<u64>();
4246 let classes = run
4247 .totals_by_language
4248 .iter()
4249 .map(|r| r.classes)
4250 .sum::<u64>();
4251 let variables = run
4252 .totals_by_language
4253 .iter()
4254 .map(|r| r.variables)
4255 .sum::<u64>();
4256 let imports = run
4257 .totals_by_language
4258 .iter()
4259 .map(|r| r.imports)
4260 .sum::<u64>();
4261
4262 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4263 let prev_fa = prev_sum.map(|s| s.files_analyzed);
4264 let prev_fs = prev_sum.map(|s| s.files_skipped);
4265 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4266 let prev_cl = prev_sum.map(|s| s.code_lines);
4267 let prev_cml = prev_sum.map(|s| s.comment_lines);
4268 let prev_bl = prev_sum.map(|s| s.blank_lines);
4269 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4270 let prev_fa_str = fmt_prev(prev_fa);
4271 let prev_fs_str = fmt_prev(prev_fs);
4272 let prev_pl_str = fmt_prev(prev_pl);
4273 let prev_cl_str = fmt_prev(prev_cl);
4274 let prev_cml_str = fmt_prev(prev_cml);
4275 let prev_bl_str = fmt_prev(prev_bl);
4276 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4277 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4278 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4279 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4280 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4281 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4282 let delta_fa_class = delta_fa_class.to_string();
4283 let delta_fs_class = delta_fs_class.to_string();
4284 let delta_pl_class = delta_pl_class.to_string();
4285 let delta_cl_class = delta_cl_class.to_string();
4286 let delta_cml_class = delta_cml_class.to_string();
4287 let delta_bl_class = delta_bl_class.to_string();
4288
4289 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4290 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4291 let (delta_lines_net_str, delta_lines_net_class) =
4292 match (delta_lines_added, delta_lines_removed) {
4293 (Some(a), Some(r)) => {
4294 let net = a - r;
4295 (fmt_delta(net), delta_class(net).to_string())
4296 }
4297 _ => ("—".to_string(), "na".to_string()),
4298 };
4299
4300 let run_dir = artifacts.output_dir.clone();
4301 let git_branch = run.git_branch.clone();
4302 let git_commit = run.git_commit_short.clone();
4303 let git_commit_long = run.git_commit_long.clone();
4304 let git_author = run.git_commit_author.clone();
4305 let git_commit_url = run
4306 .git_remote_url
4307 .as_deref()
4308 .zip(run.git_commit_long.as_deref())
4309 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4310 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4311 format!(
4312 "{} / {}",
4313 run.environment.initiator_username, run.environment.initiator_hostname
4314 )
4315 });
4316 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
4317 let os_display = format!(
4318 "{} / {}",
4319 run.environment.operating_system, run.environment.architecture
4320 );
4321 let test_count = run.summary_totals.test_count;
4322
4323 let template = ResultTemplate {
4324 version: env!("CARGO_PKG_VERSION"),
4325 report_title: run.effective_configuration.reporting.report_title.clone(),
4326 project_path: project_path.clone(),
4327 output_dir: display_path(&artifacts.output_dir),
4328 run_id: run_id.to_owned(),
4329 run_id_short: run_id
4330 .split('-')
4331 .next_back()
4332 .unwrap_or(run_id)
4333 .chars()
4334 .take(7)
4335 .collect(),
4336 files_analyzed,
4337 files_skipped,
4338 physical_lines,
4339 code_lines,
4340 comment_lines,
4341 blank_lines,
4342 mixed_lines,
4343 functions,
4344 classes,
4345 variables,
4346 imports,
4347 html_url: artifacts
4348 .html_path
4349 .as_ref()
4350 .map(|_| format!("/runs/html/{run_id}")),
4351 pdf_url: artifacts
4352 .pdf_path
4353 .as_ref()
4354 .map(|_| format!("/runs/pdf/{run_id}")),
4355 json_url: artifacts
4356 .json_path
4357 .as_ref()
4358 .map(|_| format!("/runs/json/{run_id}")),
4359 html_download_url: artifacts
4360 .html_path
4361 .as_ref()
4362 .map(|_| format!("/runs/html/{run_id}?download=1")),
4363 pdf_download_url: artifacts
4364 .pdf_path
4365 .as_ref()
4366 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4367 json_download_url: artifacts
4368 .json_path
4369 .as_ref()
4370 .map(|_| format!("/runs/json/{run_id}?download=1")),
4371 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4372 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4373 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4374 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4375 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4376 prev_fa_str,
4377 prev_fs_str,
4378 prev_pl_str,
4379 prev_cl_str,
4380 prev_cml_str,
4381 prev_bl_str,
4382 delta_fa_str,
4383 delta_fa_class,
4384 delta_fs_str,
4385 delta_fs_class,
4386 delta_pl_str,
4387 delta_pl_class,
4388 delta_cl_str,
4389 delta_cl_class,
4390 delta_cml_str,
4391 delta_cml_class,
4392 delta_bl_str,
4393 delta_bl_class,
4394 delta_lines_added,
4395 delta_lines_removed,
4396 delta_lines_net_str,
4397 delta_lines_net_class,
4398 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4399 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4400 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4401 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4402 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4403 d.file_deltas
4404 .iter()
4405 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4406 .map(|f| {
4407 #[allow(clippy::cast_sign_loss)]
4408 let n = f.current_code as u64;
4409 n
4410 })
4411 .sum()
4412 }),
4413 git_branch,
4414 git_commit,
4415 git_commit_long,
4416 git_author,
4417 git_commit_url,
4418 scan_performed_by,
4419 scan_time_display,
4420 os_display,
4421 test_count,
4422 current_scan_number: prev_scan_count + 1,
4423 prev_scan_count,
4424 submodule_rows: run
4425 .submodule_summaries
4426 .iter()
4427 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
4428 .collect(),
4429 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4430 scan_config_url: format!("/runs/scan-config/{run_id}"),
4431 lang_chart_json: {
4432 let entries: Vec<String> = run
4433 .totals_by_language
4434 .iter()
4435 .take(12)
4436 .map(|l| {
4437 let name = l
4438 .language
4439 .display_name()
4440 .replace('\\', "\\\\")
4441 .replace('"', "\\\"");
4442 format!(
4443 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4444 name,
4445 l.code_lines,
4446 l.comment_lines,
4447 l.blank_lines,
4448 l.functions,
4449 l.classes,
4450 l.variables,
4451 l.imports,
4452 l.files,
4453 )
4454 })
4455 .collect();
4456 format!("[{}]", entries.join(","))
4457 },
4458 scatter_chart_json: {
4459 let entries: Vec<String> = run
4460 .totals_by_language
4461 .iter()
4462 .map(|l| {
4463 let name = l
4464 .language
4465 .display_name()
4466 .replace('\\', "\\\\")
4467 .replace('"', "\\\"");
4468 format!(
4469 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4470 name, l.files, l.code_lines, l.total_physical_lines,
4471 )
4472 })
4473 .collect();
4474 format!("[{}]", entries.join(","))
4475 },
4476 semantic_chart_json: {
4477 let entries: Vec<String> = run
4478 .totals_by_language
4479 .iter()
4480 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
4481 .map(|l| {
4482 let name = l
4483 .language
4484 .display_name()
4485 .replace('\\', "\\\\")
4486 .replace('"', "\\\"");
4487 format!(
4488 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
4489 name, l.functions, l.classes, l.variables, l.imports,
4490 )
4491 })
4492 .collect();
4493 format!("[{}]", entries.join(","))
4494 },
4495 submodule_chart_json: {
4496 let entries: Vec<String> = run
4497 .submodule_summaries
4498 .iter()
4499 .map(|s| {
4500 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4501 format!(
4502 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4503 name,
4504 s.code_lines,
4505 s.comment_lines,
4506 s.blank_lines,
4507 s.total_physical_lines,
4508 s.files_analyzed,
4509 )
4510 })
4511 .collect();
4512 format!("[{}]", entries.join(","))
4513 },
4514 has_submodule_data: !run.submodule_summaries.is_empty(),
4515 has_semantic_data: run
4516 .totals_by_language
4517 .iter()
4518 .any(|l| l.functions > 0 || l.classes > 0),
4519 csp_nonce: csp_nonce.to_owned(),
4520 confluence_configured,
4521 server_mode,
4522 report_header_footer: run
4523 .effective_configuration
4524 .reporting
4525 .report_header_footer
4526 .clone(),
4527 };
4528
4529 Html(
4530 template
4531 .render()
4532 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4533 )
4534 .into_response()
4535}
4536
4537fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
4538 let slug: String = report_title
4539 .chars()
4540 .map(|c| {
4541 if c.is_alphanumeric() || c == '-' {
4542 c.to_ascii_lowercase()
4543 } else {
4544 '_'
4545 }
4546 })
4547 .collect::<String>()
4548 .split('_')
4549 .filter(|s| !s.is_empty())
4550 .collect::<Vec<_>>()
4551 .join("_");
4552
4553 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
4554
4555 if slug.is_empty() {
4556 format!("report_{short_id}.pdf")
4557 } else {
4558 format!("{slug}_{short_id}.pdf")
4559 }
4560}
4561
4562#[derive(Serialize)]
4563struct PdfStatusResponse {
4564 ready: bool,
4565}
4566
4567async fn pdf_status_handler(
4570 State(state): State<AppState>,
4571 AxumPath(run_id): AxumPath<String>,
4572) -> Response {
4573 let pdf_path = {
4574 let registry = state.artifacts.lock().await;
4575 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
4576 };
4577 let pdf_path = if pdf_path.is_some() {
4578 pdf_path
4579 } else {
4580 let reg = state.registry.lock().await;
4581 reg.find_by_run_id(&run_id)
4582 .map(recover_artifacts_from_registry)
4583 .and_then(|a| a.pdf_path)
4584 };
4585 let ready = pdf_path.is_some_and(|p| p.exists());
4586 Json(PdfStatusResponse { ready }).into_response()
4587}
4588
4589async fn download_bundle_handler(
4595 State(state): State<AppState>,
4596 AxumPath(run_id): AxumPath<String>,
4597) -> Response {
4598 let output_dir = {
4600 let cache = state.artifacts.lock().await;
4601 cache.get(&run_id).map(|a| a.output_dir.clone())
4602 };
4603 let output_dir = if let Some(d) = output_dir {
4604 d
4605 } else {
4606 let reg = state.registry.lock().await;
4607 match reg.find_by_run_id(&run_id) {
4608 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
4609 None => {
4610 return (
4611 StatusCode::NOT_FOUND,
4612 Json(serde_json::json!({"error": "Run not found"})),
4613 )
4614 .into_response();
4615 }
4616 }
4617 };
4618
4619 if !output_dir.exists() {
4620 return (
4621 StatusCode::NOT_FOUND,
4622 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
4623 )
4624 .into_response();
4625 }
4626
4627 let run_id_clone = run_id.clone();
4629 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
4630 use flate2::{write::GzEncoder, Compression};
4631 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
4632 {
4633 let mut tar = tar::Builder::new(&mut enc);
4634 tar.follow_symlinks(false);
4635 if let Ok(entries) = std::fs::read_dir(&output_dir) {
4638 for entry in entries.filter_map(Result::ok) {
4639 let p = entry.path();
4640 if p.is_file() {
4641 let name = p.file_name().unwrap_or_default().to_string_lossy();
4642 let archive_path = format!("{run_id_clone}/{name}");
4643 tar.append_path_with_name(&p, &archive_path)?;
4644 }
4645 }
4646 }
4647 tar.finish()?;
4648 }
4649 Ok(enc.finish()?)
4650 })
4651 .await;
4652
4653 match archive_result {
4654 Ok(Ok(bytes)) => {
4655 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
4656 axum::response::Response::builder()
4657 .status(StatusCode::OK)
4658 .header("Content-Type", "application/gzip")
4659 .header(
4660 "Content-Disposition",
4661 format!("attachment; filename=\"{filename}\""),
4662 )
4663 .header("Content-Length", bytes.len().to_string())
4664 .body(axum::body::Body::from(bytes))
4665 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
4666 }
4667 Ok(Err(e)) => (
4668 StatusCode::INTERNAL_SERVER_ERROR,
4669 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
4670 )
4671 .into_response(),
4672 Err(e) => (
4673 StatusCode::INTERNAL_SERVER_ERROR,
4674 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
4675 )
4676 .into_response(),
4677 }
4678}
4679
4680async fn delete_run_handler(
4685 State(state): State<AppState>,
4686 AxumPath(run_id): AxumPath<String>,
4687) -> Response {
4688 let output_dir = {
4690 let mut cache = state.artifacts.lock().await;
4691 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
4692 cache.remove(&run_id);
4693 dir
4694 };
4695 let output_dir = if let Some(d) = output_dir {
4696 d
4697 } else {
4698 let reg = state.registry.lock().await;
4699 reg.find_by_run_id(&run_id)
4700 .map(|e| recover_artifacts_from_registry(e).output_dir)
4701 .unwrap_or_default()
4702 };
4703
4704 {
4706 let mut reg = state.registry.lock().await;
4707 reg.entries.retain(|e| e.run_id != run_id);
4708 let _ = reg.save(&state.registry_path);
4709 }
4710
4711 if output_dir.exists() {
4713 if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
4714 return (
4715 StatusCode::INTERNAL_SERVER_ERROR,
4716 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
4717 )
4718 .into_response();
4719 }
4720 }
4721
4722 StatusCode::NO_CONTENT.into_response()
4723}
4724
4725async fn cleanup_runs_handler(
4730 State(state): State<AppState>,
4731 Json(body): Json<serde_json::Value>,
4732) -> Response {
4733 let days = body
4734 .get("older_than_days")
4735 .and_then(serde_json::Value::as_u64)
4736 .unwrap_or(30)
4737 .max(1);
4738
4739 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
4740
4741 let expired: Vec<(String, PathBuf)> = {
4743 let reg = state.registry.lock().await;
4744 reg.entries
4745 .iter()
4746 .filter(|e| e.timestamp_utc < cutoff)
4747 .map(|e| {
4748 let arts = recover_artifacts_from_registry(e);
4749 (e.run_id.clone(), arts.output_dir)
4750 })
4751 .collect()
4752 };
4753
4754 let mut deleted = 0usize;
4755 for (run_id, output_dir) in &expired {
4756 state.artifacts.lock().await.remove(run_id);
4758 if output_dir.exists() {
4760 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
4761 eprintln!(
4762 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
4763 output_dir.display()
4764 );
4765 continue;
4766 }
4767 }
4768 deleted += 1;
4769 }
4770
4771 let expired_ids: std::collections::HashSet<&str> =
4773 expired.iter().map(|(id, _)| id.as_str()).collect();
4774 {
4775 let mut reg = state.registry.lock().await;
4776 reg.entries
4777 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
4778 let _ = reg.save(&state.registry_path);
4779 }
4780
4781 Json(serde_json::json!({ "deleted": deleted })).into_response()
4782}
4783
4784fn swap_inline_chart_js_for_static(html: String) -> String {
4790 let Some(head_end) = html.find("</head>") else {
4791 return html;
4792 };
4793 let Some(script_start) = html[..head_end].rfind("<script") else {
4794 return html;
4795 };
4796 let Some(close_offset) = html[script_start..].find("</script>") else {
4797 return html;
4798 };
4799 let block_end = script_start + close_offset + "</script>".len();
4800 format!(
4801 "{}<script src=\"/static/chart-report.js\"></script>{}",
4802 &html[..script_start],
4803 &html[block_end..]
4804 )
4805}
4806
4807fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
4809 let Some(start) = html.find("nonce=\"") else {
4811 return html
4815 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
4816 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
4817 };
4818 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
4820 return html.to_owned();
4821 };
4822 let old_nonce = &html[value_start..value_start + end_offset];
4823 html.replace(
4824 &format!("nonce=\"{old_nonce}\""),
4825 &format!("nonce=\"{new_nonce}\""),
4826 )
4827}
4828
4829fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4830 match fs::read_to_string(path) {
4831 Ok(raw) => {
4832 let content = patch_html_nonce(&raw, csp_nonce);
4834 if wants_download {
4835 (
4837 [
4838 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
4839 (
4840 header::CONTENT_DISPOSITION,
4841 "attachment; filename=report.html",
4842 ),
4843 ],
4844 content,
4845 )
4846 .into_response()
4847 } else {
4848 Html(swap_inline_chart_js_for_static(content)).into_response()
4851 }
4852 }
4853 Err(err) => {
4854 let filename = path.file_name().map_or_else(
4855 || "report.html".to_string(),
4856 |n| n.to_string_lossy().into_owned(),
4857 );
4858 let msg = format!(
4859 "HTML report '{filename}' could not be read.\n\n\
4860 Error: {err}\n\n\
4861 If you moved or renamed the output folder, the stored path is now stale. \
4862 Use 'Open HTML folder' from the results page to browse the output directory."
4863 );
4864 let html = ErrorTemplate {
4865 message: msg,
4866 last_report_url: Some("/view-reports".to_string()),
4867 last_report_label: Some("View Reports".to_string()),
4868 run_id: None,
4869 error_code: Some(404),
4870 csp_nonce: csp_nonce.to_owned(),
4871 version: env!("CARGO_PKG_VERSION"),
4872 }
4873 .render()
4874 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4875 (StatusCode::NOT_FOUND, Html(html)).into_response()
4876 }
4877 }
4878}
4879
4880fn serve_pdf_artifact(
4882 path: &Path,
4883 report_title: &str,
4884 run_id: &str,
4885 wants_download: bool,
4886 csp_nonce: &str,
4887) -> Response {
4888 match fs::read(path) {
4889 Ok(bytes) => {
4890 let filename = build_pdf_filename(report_title, run_id);
4891 let disposition = if wants_download {
4892 format!("attachment; filename=\"{filename}\"")
4893 } else {
4894 format!("inline; filename=\"{filename}\"")
4895 };
4896 (
4897 [
4898 (header::CONTENT_TYPE, "application/pdf".to_string()),
4899 (header::CONTENT_DISPOSITION, disposition),
4900 ],
4901 bytes,
4902 )
4903 .into_response()
4904 }
4905 Err(err) => {
4906 let filename = path.file_name().map_or_else(
4907 || "report.pdf".to_string(),
4908 |n| n.to_string_lossy().into_owned(),
4909 );
4910 let msg = format!(
4911 "PDF report '{filename}' could not be read.\n\n\
4912 Error: {err}\n\n\
4913 If you moved or renamed the output folder, the stored path is now stale. \
4914 Use 'Open PDF folder' from the results page to browse the output directory."
4915 );
4916 let html = ErrorTemplate {
4917 message: msg,
4918 last_report_url: Some("/view-reports".to_string()),
4919 last_report_label: Some("View Reports".to_string()),
4920 run_id: Some(run_id.to_owned()),
4921 error_code: Some(404),
4922 csp_nonce: csp_nonce.to_owned(),
4923 version: env!("CARGO_PKG_VERSION"),
4924 }
4925 .render()
4926 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4927 (StatusCode::NOT_FOUND, Html(html)).into_response()
4928 }
4929 }
4930}
4931
4932fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4934 match fs::read(path) {
4935 Ok(bytes) => {
4936 if wants_download {
4937 (
4938 [
4939 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
4940 (
4941 header::CONTENT_DISPOSITION,
4942 "attachment; filename=result.json",
4943 ),
4944 ],
4945 bytes,
4946 )
4947 .into_response()
4948 } else {
4949 (
4950 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
4951 bytes,
4952 )
4953 .into_response()
4954 }
4955 }
4956 Err(err) => {
4957 let filename = path.file_name().map_or_else(
4958 || "result.json".to_string(),
4959 |n| n.to_string_lossy().into_owned(),
4960 );
4961 let msg = format!(
4962 "JSON result '{filename}' could not be read.\n\n\
4963 Error: {err}\n\n\
4964 If you moved or renamed the output folder, the stored path is now stale. \
4965 Use 'Open JSON folder' from the results page to browse the output directory."
4966 );
4967 let html = ErrorTemplate {
4968 message: msg,
4969 last_report_url: Some("/view-reports".to_string()),
4970 last_report_label: Some("View Reports".to_string()),
4971 run_id: None,
4972 error_code: Some(404),
4973 csp_nonce: csp_nonce.to_owned(),
4974 version: env!("CARGO_PKG_VERSION"),
4975 }
4976 .render()
4977 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4978 (StatusCode::NOT_FOUND, Html(html)).into_response()
4979 }
4980 }
4981}
4982
4983fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4985 let output_dir = entry
4986 .html_path
4987 .as_ref()
4988 .or(entry.json_path.as_ref())
4989 .or(entry.pdf_path.as_ref())
4990 .or(entry.csv_path.as_ref())
4991 .or(entry.xlsx_path.as_ref())
4992 .and_then(|p| p.parent().map(PathBuf::from))
4993 .unwrap_or_default();
4994 let pdf_path = entry.pdf_path.clone().or_else(|| {
4997 let candidate = output_dir.join("report.pdf");
4998 candidate.exists().then_some(candidate)
4999 });
5000 let csv_path = entry.csv_path.clone().or_else(|| {
5004 fs::read_dir(&output_dir).ok().and_then(|entries| {
5005 entries
5006 .filter_map(std::result::Result::ok)
5007 .find(|e| {
5008 let n = e.file_name();
5009 let n = n.to_string_lossy();
5010 n.starts_with("report_") && n.ends_with(".csv")
5011 })
5012 .map(|e| e.path())
5013 })
5014 });
5015 let xlsx_path = entry.xlsx_path.clone().or_else(|| {
5016 fs::read_dir(&output_dir).ok().and_then(|entries| {
5017 entries
5018 .filter_map(std::result::Result::ok)
5019 .find(|e| {
5020 let n = e.file_name();
5021 let n = n.to_string_lossy();
5022 n.starts_with("report_") && n.ends_with(".xlsx")
5023 })
5024 .map(|e| e.path())
5025 })
5026 });
5027 RunArtifacts {
5028 output_dir: output_dir.clone(),
5029 html_path: entry.html_path.clone(),
5030 pdf_path,
5031 json_path: entry.json_path.clone(),
5032 csv_path,
5033 xlsx_path,
5034 scan_config_path: find_scan_config_in_dir(&output_dir),
5035 report_title: entry.project_label.clone(),
5036 result_context: RunResultContext::default(),
5037 }
5038}
5039
5040#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
5042 state: &AppState,
5043 run_id: &str,
5044 csp_nonce: &str,
5045) -> Result<RunArtifacts, Response> {
5046 let cached = state.artifacts.lock().await.get(run_id).cloned();
5047 if let Some(a) = cached {
5048 return Ok(a);
5049 }
5050 let reg = state.registry.lock().await;
5051 if let Some(entry) = reg.find_by_run_id(run_id) {
5052 return Ok(recover_artifacts_from_registry(entry));
5053 }
5054 drop(reg);
5055 let short_id = &run_id[..run_id.len().min(8)];
5056 let hint = if matches!(
5057 run_id,
5058 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
5059 ) {
5060 format!(
5061 " The URL format appears to be reversed — \
5062 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
5063 Use the View Reports page to navigate to your scan."
5064 )
5065 } else {
5066 " The report may have been deleted or the report directory moved. \
5067 Use View Reports to browse your scan history."
5068 .to_string()
5069 };
5070 let error_html = ErrorTemplate {
5071 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
5072 last_report_url: Some("/view-reports".to_string()),
5073 last_report_label: Some("View Reports".to_string()),
5074 run_id: None,
5075 error_code: Some(404),
5076 csp_nonce: csp_nonce.to_owned(),
5077 version: env!("CARGO_PKG_VERSION"),
5078 }
5079 .render()
5080 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5081 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
5082}
5083
5084async fn resolve_or_queue_pdf(
5089 state: &AppState,
5090 pdf_path: Option<PathBuf>,
5091 json_path: Option<PathBuf>,
5092 output_dir: PathBuf,
5093 run_id: &str,
5094 report_title: &str,
5095 csp_nonce: &str,
5096) -> Result<PathBuf, Response> {
5097 if let Some(p) = pdf_path {
5098 return Ok(p);
5099 }
5100 let Some(json_src) = json_path.filter(|p| p.exists()) else {
5101 let msg = "PDF report was not generated for this run. \
5102 Re-run the analysis with PDF output enabled."
5103 .to_string();
5104 let html = ErrorTemplate {
5105 message: msg,
5106 last_report_url: Some(format!("/runs/html/{run_id}")),
5107 last_report_label: Some("View HTML Report".to_string()),
5108 run_id: Some(run_id.to_string()),
5109 error_code: Some(404),
5110 csp_nonce: csp_nonce.to_string(),
5111 version: env!("CARGO_PKG_VERSION"),
5112 }
5113 .render()
5114 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
5115 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5116 };
5117 let pdf_filename = build_pdf_filename(report_title, run_id);
5118 let pdf_dest = output_dir.join(&pdf_filename);
5119 if !pdf_dest.exists() {
5120 {
5122 let mut map = state.artifacts.lock().await;
5123 if let Some(entry) = map.get_mut(run_id) {
5124 entry.pdf_path = Some(pdf_dest.clone());
5125 }
5126 }
5127 {
5128 let mut reg = state.registry.lock().await;
5129 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
5130 e.pdf_path = Some(pdf_dest.clone());
5131 }
5132 let _ = reg.save(&state.registry_path);
5133 }
5134 spawn_native_pdf_background(
5135 json_src,
5136 pdf_dest.clone(),
5137 run_id.to_string(),
5138 state.artifacts.clone(),
5139 );
5140 }
5141 Ok(pdf_dest)
5142}
5143
5144fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
5146 let html = format!(
5147 "<!doctype html><html lang=\"en\"><head>\
5148 <meta charset=utf-8>\
5149 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
5150 <meta http-equiv=\"refresh\" content=\"5\">\
5151 <title>OxideSLOC | Generating PDF\u{2026}</title>\
5152 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
5153 <style nonce=\"{csp_nonce}\">\
5154 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
5155 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
5156 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
5157 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
5158 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
5159 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
5160 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
5161 background:var(--bg);color:var(--text);}}\
5162 .top-nav{{position:sticky;top:0;z-index:30;\
5163 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
5164 border-bottom:1px solid rgba(255,255,255,0.12);\
5165 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
5166 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
5167 min-height:56px;display:flex;align-items:center;gap:14px;}}\
5168 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
5169 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
5170 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
5171 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
5172 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
5173 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
5174 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
5175 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
5176 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
5177 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
5178 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
5179 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
5180 justify-content:center;min-height:38px;border-radius:999px;\
5181 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
5182 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
5183 .theme-toggle .icon-sun{{display:none;}}\
5184 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
5185 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
5186 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
5187 display:flex;align-items:center;justify-content:center;\
5188 min-height:calc(100vh - 56px);}}\
5189 .panel{{background:var(--surface);border:1px solid var(--line);\
5190 border-radius:var(--radius);box-shadow:var(--shadow);\
5191 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
5192 .spin-ring{{width:56px;height:56px;border-radius:50%;\
5193 border:5px solid var(--line);border-top-color:var(--oxide-2);\
5194 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
5195 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
5196 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
5197 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
5198 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
5199 min-height:42px;padding:0 20px;border-radius:14px;\
5200 border:1px solid var(--line-strong);text-decoration:none;\
5201 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
5202 .back-link:hover{{background:var(--line);}}\
5203 </style></head>\
5204 <body>\
5205 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
5206 <a class=\"brand\" href=\"/\">\
5207 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
5208 <div class=\"brand-copy\">\
5209 <div class=\"brand-title\">OxideSLOC</div>\
5210 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5211 </div>\
5212 </a>\
5213 <div class=\"nav-right\">\
5214 <a class=\"nav-pill\" href=\"/\">Home</a>\
5215 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
5216 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
5217 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5218 <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>\
5219 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5220 <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>\
5221 </button>\
5222 </div>\
5223 </div></div>\
5224 <div class=\"page\"><div class=\"panel\">\
5225 <div class=\"spin-ring\"></div>\
5226 <h1>Generating PDF\u{2026}</h1>\
5227 <p>The PDF is being generated from the scan results.<br>\
5228 This page refreshes automatically \u{2014} usually a few seconds.</p>\
5229 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5230 </div></div>\
5231 <script nonce=\"{csp_nonce}\">\
5232 (function(){{\
5233 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5234 if(s===\"dark\")b.classList.add(\"dark-theme\");\
5235 var t=document.getElementById(\"theme-toggle\");\
5236 if(t)t.addEventListener(\"click\",function(){{\
5237 var d=b.classList.toggle(\"dark-theme\");\
5238 localStorage.setItem(k,d?\"dark\":\"light\");\
5239 }});\
5240 }})();\
5241 </script>\
5242 </body></html>"
5243 );
5244 Html(html).into_response()
5245}
5246
5247async fn artifact_handler(
5248 State(state): State<AppState>,
5249 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5250 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
5251 Query(query): Query<ArtifactQuery>,
5252) -> Response {
5253 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
5254 Ok(a) => a,
5255 Err(r) => return r,
5256 };
5257
5258 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
5259
5260 match artifact.as_str() {
5261 "html" => {
5262 let Some(path) = artifact_set.html_path else {
5263 return StatusCode::NOT_FOUND.into_response();
5264 };
5265 serve_html_artifact(&path, wants_download, &csp_nonce)
5266 }
5267 "pdf" => {
5268 let report_title = artifact_set.report_title.clone();
5269 let path = match resolve_or_queue_pdf(
5270 &state,
5271 artifact_set.pdf_path,
5272 artifact_set.json_path.clone(),
5273 artifact_set.output_dir.clone(),
5274 &run_id,
5275 &report_title,
5276 &csp_nonce,
5277 )
5278 .await
5279 {
5280 Ok(p) => p,
5281 Err(r) => return r,
5282 };
5283 if !path.exists() {
5286 return pdf_generating_response(&run_id, &csp_nonce);
5287 }
5288 serve_pdf_artifact(&path, &report_title, &run_id, wants_download, &csp_nonce)
5289 }
5290 "json" => {
5291 let Some(path) = artifact_set.json_path else {
5292 let msg = "JSON result was not generated for this run, or was not recorded in \
5293 the scan registry. Re-run the analysis with JSON output enabled."
5294 .to_string();
5295 let html = ErrorTemplate {
5296 message: msg,
5297 last_report_url: Some("/view-reports".to_string()),
5298 last_report_label: Some("View Reports".to_string()),
5299 run_id: Some(run_id.clone()),
5300 error_code: Some(404),
5301 csp_nonce: csp_nonce.clone(),
5302 version: env!("CARGO_PKG_VERSION"),
5303 }
5304 .render()
5305 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
5306 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5307 };
5308 serve_json_artifact(&path, wants_download, &csp_nonce)
5309 }
5310 "csv" => {
5311 let Some(path) = artifact_set.csv_path else {
5312 let msg = "CSV report was not generated for this run, or was not recorded in \
5313 the scan registry."
5314 .to_string();
5315 let html = ErrorTemplate {
5316 message: msg,
5317 last_report_url: Some(format!("/runs/html/{run_id}")),
5318 last_report_label: Some("View HTML Report".to_string()),
5319 run_id: Some(run_id.clone()),
5320 error_code: Some(404),
5321 csp_nonce: csp_nonce.clone(),
5322 version: env!("CARGO_PKG_VERSION"),
5323 }
5324 .render()
5325 .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
5326 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5327 };
5328 fs::read(&path).map_or_else(
5329 |_| StatusCode::NOT_FOUND.into_response(),
5330 |bytes| {
5331 let filename = path.file_name().map_or_else(
5332 || "report.csv".to_string(),
5333 |n| n.to_string_lossy().into_owned(),
5334 );
5335 (
5336 [
5337 (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
5338 (
5339 header::CONTENT_DISPOSITION,
5340 format!("attachment; filename=\"{filename}\""),
5341 ),
5342 ],
5343 bytes,
5344 )
5345 .into_response()
5346 },
5347 )
5348 }
5349 "xlsx" => {
5350 let Some(path) = artifact_set.xlsx_path else {
5351 let msg = "Excel report was not generated for this run, or was not recorded in \
5352 the scan registry."
5353 .to_string();
5354 let html = ErrorTemplate {
5355 message: msg,
5356 last_report_url: Some(format!("/runs/html/{run_id}")),
5357 last_report_label: Some("View HTML Report".to_string()),
5358 run_id: Some(run_id.clone()),
5359 error_code: Some(404),
5360 csp_nonce: csp_nonce.clone(),
5361 version: env!("CARGO_PKG_VERSION"),
5362 }
5363 .render()
5364 .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
5365 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5366 };
5367 fs::read(&path).map_or_else(
5368 |_| StatusCode::NOT_FOUND.into_response(),
5369 |bytes| {
5370 let filename = path.file_name().map_or_else(
5371 || "report.xlsx".to_string(),
5372 |n| n.to_string_lossy().into_owned(),
5373 );
5374 (
5375 [
5376 (
5377 header::CONTENT_TYPE,
5378 "application/vnd.openxmlformats-officedocument\
5379 .spreadsheetml.sheet"
5380 .to_string(),
5381 ),
5382 (
5383 header::CONTENT_DISPOSITION,
5384 format!("attachment; filename=\"{filename}\""),
5385 ),
5386 ],
5387 bytes,
5388 )
5389 .into_response()
5390 },
5391 )
5392 }
5393 "scan-config" => {
5394 let path = artifact_set
5395 .scan_config_path
5396 .as_deref()
5397 .map(std::path::Path::to_path_buf)
5398 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
5399 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
5400 fs::read(&path).map_or_else(
5401 |_| StatusCode::NOT_FOUND.into_response(),
5402 |bytes| {
5403 (
5404 [
5405 (
5406 header::CONTENT_TYPE,
5407 "application/json; charset=utf-8".to_string(),
5408 ),
5409 (
5410 header::CONTENT_DISPOSITION,
5411 "attachment; filename=\"scan-config.json\"".to_string(),
5412 ),
5413 ],
5414 bytes,
5415 )
5416 .into_response()
5417 },
5418 )
5419 }
5420 _ if artifact.starts_with("sub_") => {
5421 if artifact.len() > 128
5422 || !artifact
5423 .chars()
5424 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
5425 {
5426 return StatusCode::BAD_REQUEST.into_response();
5427 }
5428 let filename = format!("{artifact}.html");
5429 let path = artifact_set.output_dir.join(&filename);
5430 if !path.exists() {
5431 let html = ErrorTemplate {
5432 message: format!(
5433 "Sub-report '{artifact}' was not found in the run directory.\n\
5434 Re-run the analysis with 'Detect and separate git submodules' \
5435 and HTML output enabled."
5436 ),
5437 last_report_url: Some("/view-reports".to_string()),
5438 last_report_label: Some("View Reports".to_string()),
5439 run_id: Some(run_id.clone()),
5440 error_code: Some(404),
5441 csp_nonce: csp_nonce.clone(),
5442 version: env!("CARGO_PKG_VERSION"),
5443 }
5444 .render()
5445 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
5446 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5447 }
5448 serve_html_artifact(&path, wants_download, &csp_nonce)
5449 }
5450 _ => StatusCode::NOT_FOUND.into_response(),
5451 }
5452}
5453
5454struct SubmoduleLinkRow {
5457 name: String,
5458 url: String,
5459}
5460
5461struct HistoryEntryRow {
5462 run_id: String,
5463 run_id_short: String,
5464 timestamp: String,
5465 timestamp_utc_ms: i64,
5466 project_label: String,
5467 project_path: String,
5468 files_analyzed: u64,
5469 files_skipped: u64,
5470 code_lines: u64,
5471 comment_lines: u64,
5472 blank_lines: u64,
5473 git_branch: String,
5474 git_commit: String,
5475 has_html: bool,
5476 has_json: bool,
5477 has_pdf: bool,
5478 submodule_links: Vec<SubmoduleLinkRow>,
5479 submodule_names_csv: String,
5481}
5482
5483fn nth_weekday_of_month(
5485 year: i32,
5486 month: u32,
5487 weekday: chrono::Weekday,
5488 n: u32,
5489) -> chrono::NaiveDate {
5490 use chrono::Datelike;
5491 let mut count = 0u32;
5492 let mut day = 1u32;
5493 loop {
5494 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
5495 if d.weekday() == weekday {
5496 count += 1;
5497 if count == n {
5498 return d;
5499 }
5500 }
5501 day += 1;
5502 }
5503}
5504
5505fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
5509 use chrono::{Datelike, TimeZone};
5510 let year = dt.year();
5511 let dst_start = chrono::Utc.from_utc_datetime(
5512 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
5513 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
5514 );
5515 let dst_end = chrono::Utc.from_utc_datetime(
5516 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
5517 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
5518 );
5519 dt >= dst_start && dt < dst_end
5520}
5521
5522fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
5523 if is_pacific_dst(dt) {
5524 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
5525 .format("%Y-%m-%d %H:%M PDT")
5526 .to_string()
5527 } else {
5528 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
5529 .format("%Y-%m-%d %H:%M PST")
5530 .to_string()
5531 }
5532}
5533
5534fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
5536 let (offset, tz) = if is_pacific_dst(dt) {
5537 (
5538 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
5539 "PDT",
5540 )
5541 } else {
5542 (
5543 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
5544 "PST",
5545 )
5546 };
5547 format!(
5548 "{} {tz}",
5549 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
5550 )
5551}
5552
5553fn fmt_git_date(iso: &str) -> Option<String> {
5554 chrono::DateTime::parse_from_rfc3339(iso)
5555 .ok()
5556 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
5557}
5558
5559fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
5560 reg.entries
5561 .iter()
5562 .map(|e| {
5563 let submodule_links = {
5564 let mut links: Vec<SubmoduleLinkRow> = vec![];
5565 let sub_dir = e
5566 .html_path
5567 .as_ref()
5568 .and_then(|p| p.parent())
5569 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5570 if let Some(dir) = sub_dir {
5571 if let Ok(rd) = std::fs::read_dir(dir) {
5572 for entry_res in rd.flatten() {
5573 let fname = entry_res.file_name();
5574 let fname_str = fname.to_string_lossy();
5575 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5576 let stem = &fname_str[..fname_str.len() - 5];
5577 let display = stem[4..].replace('-', " ");
5578 links.push(SubmoduleLinkRow {
5579 name: display,
5580 url: format!("/runs/{stem}/{}", e.run_id),
5581 });
5582 }
5583 }
5584 }
5585 }
5586 links.sort_by(|a, b| a.name.cmp(&b.name));
5587 links
5588 };
5589 let submodule_names_csv = submodule_links
5590 .iter()
5591 .map(|l| l.name.as_str())
5592 .collect::<Vec<_>>()
5593 .join(",");
5594 HistoryEntryRow {
5595 run_id: e.run_id.clone(),
5596 run_id_short: e
5597 .run_id
5598 .split('-')
5599 .next_back()
5600 .unwrap_or(&e.run_id)
5601 .chars()
5602 .take(7)
5603 .collect(),
5604 timestamp: fmt_la_time(e.timestamp_utc),
5605 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
5606 project_label: e.project_label.clone(),
5607 project_path: e
5608 .input_roots
5609 .first()
5610 .map(|s| sanitize_path_str(s))
5611 .unwrap_or_default(),
5612 files_analyzed: e.summary.files_analyzed,
5613 files_skipped: e.summary.files_skipped,
5614 code_lines: e.summary.code_lines,
5615 comment_lines: e.summary.comment_lines,
5616 blank_lines: e.summary.blank_lines,
5617 git_branch: e.git_branch.clone().unwrap_or_default(),
5618 git_commit: e.git_commit.clone().unwrap_or_default(),
5619 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
5620 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
5621 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
5622 submodule_links,
5623 submodule_names_csv,
5624 }
5625 })
5626 .collect()
5627}
5628
5629#[derive(Deserialize, Default)]
5630struct HistoryQuery {
5631 linked: Option<String>,
5632 error: Option<String>,
5633}
5634
5635async fn history_handler(
5636 State(state): State<AppState>,
5637 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5638 Query(query): Query<HistoryQuery>,
5639) -> impl IntoResponse {
5640 auto_scan_watched_dirs(&state).await;
5642 let watched_dirs: Vec<String> = {
5643 let wd = state.watched_dirs.lock().await;
5644 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5645 };
5646 let mut entries = {
5647 let reg = state.registry.lock().await;
5648 make_history_rows(®)
5649 };
5650 entries.retain(|e| e.has_html);
5651 let total_scans = entries.len();
5652 let linked_count = query
5653 .linked
5654 .as_deref()
5655 .and_then(|s| s.parse::<usize>().ok())
5656 .unwrap_or(0);
5657 let browse_error = query.error.filter(|s| !s.is_empty());
5658 let template = HistoryTemplate {
5659 version: env!("CARGO_PKG_VERSION"),
5660 entries,
5661 total_scans,
5662 linked_count,
5663 browse_error,
5664 watched_dirs,
5665 csp_nonce,
5666 server_mode: state.server_mode,
5667 };
5668 Html(
5669 template
5670 .render()
5671 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5672 )
5673 .into_response()
5674}
5675
5676async fn compare_select_handler(
5677 State(state): State<AppState>,
5678 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5679) -> impl IntoResponse {
5680 auto_scan_watched_dirs(&state).await;
5681 let watched_dirs: Vec<String> = {
5682 let wd = state.watched_dirs.lock().await;
5683 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5684 };
5685 let mut entries = {
5686 let reg = state.registry.lock().await;
5687 make_history_rows(®)
5688 };
5689 entries.retain(|e| e.has_json);
5690 let total_scans = entries.len();
5691 let template = CompareSelectTemplate {
5692 version: env!("CARGO_PKG_VERSION"),
5693 entries,
5694 total_scans,
5695 watched_dirs,
5696 csp_nonce,
5697 server_mode: state.server_mode,
5698 };
5699 Html(
5700 template
5701 .render()
5702 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5703 )
5704 .into_response()
5705}
5706
5707#[derive(Deserialize, Default)]
5710struct CompareQuery {
5711 a: Option<String>,
5712 b: Option<String>,
5713 sub: Option<String>,
5715 scope: Option<String>,
5717}
5718
5719struct CompareFileDeltaRow {
5720 relative_path: String,
5721 language: String,
5722 status: String,
5723 baseline_code: i64,
5724 current_code: i64,
5725 code_delta_str: String,
5726 code_delta_class: String,
5727 comment_delta_str: String,
5728 comment_delta_class: String,
5729 total_delta_str: String,
5730 total_delta_class: String,
5731}
5732
5733fn recompute_summary_from_records(run: &mut AnalysisRun) {
5736 let files_analyzed = run
5737 .per_file_records
5738 .iter()
5739 .filter(|r| r.language.is_some())
5740 .count() as u64;
5741 let code_lines: u64 = run
5742 .per_file_records
5743 .iter()
5744 .map(|r| r.effective_counts.code_lines)
5745 .sum();
5746 let comment_lines: u64 = run
5747 .per_file_records
5748 .iter()
5749 .map(|r| r.effective_counts.comment_lines)
5750 .sum();
5751 let blank_lines: u64 = run
5752 .per_file_records
5753 .iter()
5754 .map(|r| r.effective_counts.blank_lines)
5755 .sum();
5756 run.summary_totals.files_analyzed = files_analyzed;
5757 run.summary_totals.files_considered = files_analyzed;
5758 run.summary_totals.code_lines = code_lines;
5759 run.summary_totals.comment_lines = comment_lines;
5760 run.summary_totals.blank_lines = blank_lines;
5761 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
5762}
5763
5764fn fmt_delta(n: i64) -> String {
5765 if n > 0 {
5766 format!("+{n}")
5767 } else {
5768 format!("{n}")
5769 }
5770}
5771
5772fn delta_class(n: i64) -> &'static str {
5773 use std::cmp::Ordering;
5774 match n.cmp(&0) {
5775 Ordering::Greater => "pos",
5776 Ordering::Less => "neg",
5777 Ordering::Equal => "zero",
5778 }
5779}
5780
5781#[allow(clippy::cast_precision_loss)]
5783fn fmt_pct(delta: i64, baseline: u64) -> String {
5784 if baseline == 0 {
5785 return "—".to_string();
5786 }
5787 #[allow(clippy::cast_precision_loss)]
5788 let pct = (delta as f64 / baseline as f64) * 100.0;
5789 if pct > 0.049 {
5790 format!("+{pct:.1}%")
5791 } else if pct < -0.049 {
5792 format!("{pct:.1}%")
5793 } else {
5794 "±0%".to_string()
5795 }
5796}
5797
5798fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
5800 prev.map_or_else(
5801 || ("—".to_string(), "na"),
5802 |p| {
5803 #[allow(clippy::cast_possible_wrap)]
5804 let d = curr as i64 - p as i64;
5805 (fmt_delta(d), delta_class(d))
5806 },
5807 )
5808}
5809
5810#[allow(clippy::result_large_err)] fn load_scan_for_compare(
5812 json_path: &std::path::Path,
5813 scan_label: &str,
5814 run_id: &str,
5815 server_mode: bool,
5816 compare_url: &str,
5817 csp_nonce: &str,
5818) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
5819 match read_json(json_path) {
5820 Ok(r) => Ok(r),
5821 Err(e) => {
5822 if server_mode {
5823 let html = ErrorTemplate {
5824 message: format!(
5825 "Could not load {scan_label} scan data. The scan output folder may have \
5826 been moved, renamed, or deleted. Re-running the analysis will create \
5827 fresh comparison data."
5828 ),
5829 last_report_url: Some("/compare-scans".to_string()),
5830 last_report_label: Some("Compare Scans".to_string()),
5831 run_id: Some(run_id.to_owned()),
5832 error_code: Some(404),
5833 csp_nonce: csp_nonce.to_owned(),
5834 version: env!("CARGO_PKG_VERSION"),
5835 }
5836 .render()
5837 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
5838 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5839 }
5840 let msg = format!(
5841 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
5842 json_path.display()
5843 );
5844 let folder_hint = json_path
5845 .parent()
5846 .map(|p| p.display().to_string())
5847 .unwrap_or_default();
5848 Err(missing_scan_relocate_response(
5849 &msg,
5850 run_id,
5851 &folder_hint,
5852 compare_url,
5853 false,
5854 csp_nonce,
5855 ))
5856 }
5857 }
5858}
5859
5860struct ChurnStats {
5861 new_scope: bool,
5862 scope_flag: bool,
5863 churn_rate_str: String,
5864 churn_rate_class: String,
5865}
5866
5867fn compute_churn_stats(
5868 baseline_code: u64,
5869 current_code: u64,
5870 lines_added: i64,
5871 lines_removed: i64,
5872) -> ChurnStats {
5873 let new_scope = baseline_code == 0 && current_code > 0;
5874 #[allow(clippy::cast_precision_loss)]
5875 let churn_pct = if baseline_code > 0 {
5876 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
5877 } else {
5878 0.0
5879 };
5880 #[allow(clippy::cast_precision_loss)]
5881 let scope_flag =
5882 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
5883 let churn_rate_str = if new_scope {
5884 "New".to_string()
5885 } else if baseline_code > 0 {
5886 format!("{churn_pct:.1}%")
5887 } else {
5888 "—".to_string()
5889 };
5890 let churn_rate_class = if new_scope || churn_pct > 20.0 {
5891 "high".to_string()
5892 } else if churn_pct > 5.0 {
5893 "med".to_string()
5894 } else {
5895 "low".to_string()
5896 };
5897 ChurnStats {
5898 new_scope,
5899 scope_flag,
5900 churn_rate_str,
5901 churn_rate_class,
5902 }
5903}
5904
5905fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
5909 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
5910 if !has_data {
5911 return String::new();
5912 }
5913 let base_str = s
5914 .baseline_coverage_line_pct
5915 .map(|p| format!("{p:.1}%"))
5916 .unwrap_or_else(|| "\u{2014}".into());
5917 let curr_str = s
5918 .current_coverage_line_pct
5919 .map(|p| format!("{p:.1}%"))
5920 .unwrap_or_else(|| "\u{2014}".into());
5921 let (delta_str, cls) = match s.coverage_line_pct_delta {
5922 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
5923 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
5924 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
5925 None => ("\u{2014}".into(), "zero"),
5926 };
5927 format!(
5928 r#"<div class="delta-card">
5929 <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>
5930 <div class="delta-card-label">Line coverage</div>
5931 <div class="delta-card-from">Before: {base_str}</div>
5932 <div class="delta-card-to">{curr_str}</div>
5933 <span class="delta-card-change {cls}">{delta_str}</span>
5934 </div>"#
5935 )
5936}
5937
5938#[allow(clippy::too_many_lines)]
5939async fn compare_handler(
5940 State(state): State<AppState>,
5941 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5942 Query(query): Query<CompareQuery>,
5943) -> impl IntoResponse {
5944 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
5947 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
5948 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
5949 };
5950
5951 let (maybe_a, maybe_b) = {
5952 let reg = state.registry.lock().await;
5953 (
5954 reg.find_by_run_id(&run_id_a).cloned(),
5955 reg.find_by_run_id(&run_id_b).cloned(),
5956 )
5957 };
5958
5959 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
5960 let html = ErrorTemplate {
5961 message: "One or both run IDs were not found in scan history. \
5962 The runs may have been deleted or the registry may have been reset."
5963 .to_string(),
5964 last_report_url: Some("/compare-scans".to_string()),
5965 last_report_label: Some("Compare Scans".to_string()),
5966 run_id: None,
5967 error_code: None,
5968 csp_nonce: csp_nonce.clone(),
5969 version: env!("CARGO_PKG_VERSION"),
5970 }
5971 .render()
5972 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
5973 return Html(html).into_response();
5974 };
5975
5976 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
5978 (entry_a, entry_b)
5979 } else {
5980 (entry_b, entry_a)
5981 };
5982
5983 if baseline_entry.run_id != run_id_a {
5987 let canonical = format!(
5988 "/compare?a={}&b={}",
5989 baseline_entry.run_id, current_entry.run_id
5990 );
5991 return axum::response::Redirect::to(&canonical).into_response();
5992 }
5993
5994 let (Some(base_json), Some(curr_json)) = (
5995 baseline_entry.json_path.as_ref(),
5996 current_entry.json_path.as_ref(),
5997 ) else {
5998 let html = ErrorTemplate {
5999 message: "Full comparison requires JSON scan data, which was not saved for one or \
6000 both of these runs. JSON is now always saved for new scans — re-run the \
6001 affected projects to enable comparisons."
6002 .to_string(),
6003 last_report_url: Some("/compare-scans".to_string()),
6004 last_report_label: Some("Compare Scans".to_string()),
6005 run_id: None,
6006 error_code: None,
6007 csp_nonce: csp_nonce.clone(),
6008 version: env!("CARGO_PKG_VERSION"),
6009 }
6010 .render()
6011 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
6012 return Html(html).into_response();
6013 };
6014
6015 let compare_url = format!(
6016 "/compare?a={}&b={}",
6017 baseline_entry.run_id, current_entry.run_id
6018 );
6019
6020 let baseline_run = match load_scan_for_compare(
6021 base_json,
6022 "baseline",
6023 &baseline_entry.run_id,
6024 state.server_mode,
6025 &compare_url,
6026 &csp_nonce,
6027 ) {
6028 Ok(r) => r,
6029 Err(resp) => return resp,
6030 };
6031 let current_run = match load_scan_for_compare(
6032 curr_json,
6033 "current",
6034 ¤t_entry.run_id,
6035 state.server_mode,
6036 &compare_url,
6037 &csp_nonce,
6038 ) {
6039 Ok(r) => r,
6040 Err(resp) => return resp,
6041 };
6042
6043 let active_submodule = query.sub.clone();
6044 let super_scope_active = query.scope.as_deref() == Some("super");
6045
6046 let submodule_options = baseline_run
6047 .submodule_summaries
6048 .iter()
6049 .chain(current_run.submodule_summaries.iter())
6050 .map(|s| s.name.clone())
6051 .collect::<std::collections::BTreeSet<_>>()
6052 .into_iter()
6053 .collect::<Vec<_>>();
6054 let has_any_submodule_data = !submodule_options.is_empty();
6055
6056 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
6058 let mut b = baseline_run;
6059 let mut c = current_run;
6060 b.per_file_records
6061 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6062 c.per_file_records
6063 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6064 recompute_summary_from_records(&mut b);
6065 recompute_summary_from_records(&mut c);
6066 (b, c)
6067 } else if super_scope_active {
6068 let mut b = baseline_run;
6069 let mut c = current_run;
6070 b.per_file_records.retain(|f| f.submodule.is_none());
6071 c.per_file_records.retain(|f| f.submodule.is_none());
6072 recompute_summary_from_records(&mut b);
6073 recompute_summary_from_records(&mut c);
6074 (b, c)
6075 } else {
6076 (baseline_run, current_run)
6077 };
6078
6079 let comparison = compute_delta(&effective_baseline, &effective_current);
6080
6081 let file_rows: Vec<CompareFileDeltaRow> = comparison
6082 .file_deltas
6083 .iter()
6084 .map(|d| CompareFileDeltaRow {
6085 relative_path: d.relative_path.clone(),
6086 language: d.language.clone().unwrap_or_else(|| "—".into()),
6087 status: match d.status {
6088 FileChangeStatus::Added => "added".into(),
6089 FileChangeStatus::Removed => "removed".into(),
6090 FileChangeStatus::Modified => "modified".into(),
6091 FileChangeStatus::Unchanged => "unchanged".into(),
6092 },
6093 baseline_code: d.baseline_code,
6094 current_code: d.current_code,
6095 code_delta_str: fmt_delta(d.code_delta),
6096 code_delta_class: delta_class(d.code_delta).into(),
6097 comment_delta_str: fmt_delta(d.comment_delta),
6098 comment_delta_class: delta_class(d.comment_delta).into(),
6099 total_delta_str: fmt_delta(d.total_delta),
6100 total_delta_class: delta_class(d.total_delta).into(),
6101 })
6102 .collect();
6103
6104 let project_path = baseline_entry
6105 .input_roots
6106 .first()
6107 .map(|s| sanitize_path_str(s))
6108 .unwrap_or_default();
6109 let lines_added = sum_added_code_lines(&comparison);
6110 let lines_removed = sum_removed_code_lines(&comparison);
6111 let churn = compute_churn_stats(
6112 comparison.summary.baseline_code,
6113 comparison.summary.current_code,
6114 lines_added,
6115 lines_removed,
6116 );
6117 let s = &comparison.summary;
6118 let template = CompareTemplate {
6119 version: env!("CARGO_PKG_VERSION"),
6120 project_label: baseline_entry.project_label.clone(),
6121 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
6122 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
6123 baseline_run_id: baseline_entry.run_id.clone(),
6124 current_run_id: current_entry.run_id.clone(),
6125 baseline_run_id_short: baseline_entry
6126 .run_id
6127 .split('-')
6128 .next_back()
6129 .unwrap_or(&baseline_entry.run_id)
6130 .chars()
6131 .take(7)
6132 .collect(),
6133 current_run_id_short: current_entry
6134 .run_id
6135 .split('-')
6136 .next_back()
6137 .unwrap_or(¤t_entry.run_id)
6138 .chars()
6139 .take(7)
6140 .collect(),
6141 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
6142 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
6143 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
6144 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
6145 project_path: project_path.clone(),
6146 baseline_code: s.baseline_code,
6147 current_code: s.current_code,
6148 code_lines_delta_str: fmt_delta(s.code_lines_delta),
6149 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
6150 baseline_files: s.baseline_files,
6151 current_files: s.current_files,
6152 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
6153 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
6154 baseline_comments: s.baseline_comments,
6155 current_comments: s.current_comments,
6156 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
6157 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
6158 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
6159 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
6160 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
6161 code_lines_added: lines_added,
6162 code_lines_removed: lines_removed,
6163 new_scope: churn.new_scope,
6164 churn_rate_str: churn.churn_rate_str,
6165 churn_rate_class: churn.churn_rate_class,
6166 scope_flag: churn.scope_flag,
6167 files_added: comparison.files_added,
6168 files_removed: comparison.files_removed,
6169 files_modified: comparison.files_modified,
6170 files_unchanged: comparison.files_unchanged,
6171 file_rows,
6172 baseline_git_author: baseline_entry.git_author.clone(),
6173 current_git_author: current_entry.git_author.clone(),
6174 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
6175 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
6176 baseline_git_tags: baseline_entry.git_tags.clone(),
6177 current_git_tags: current_entry.git_tags.clone(),
6178 baseline_git_commit_date: baseline_entry
6179 .git_commit_date
6180 .as_deref()
6181 .and_then(fmt_git_date),
6182 current_git_commit_date: current_entry
6183 .git_commit_date
6184 .as_deref()
6185 .and_then(fmt_git_date),
6186 project_name: project_path
6187 .rsplit(['/', '\\'])
6188 .find(|s| !s.is_empty())
6189 .unwrap_or(&project_path)
6190 .to_string(),
6191 submodule_options,
6192 has_any_submodule_data,
6193 active_submodule,
6194 super_scope_active,
6195 csp_nonce,
6196 coverage_delta_card: build_coverage_delta_card(s),
6197 };
6198
6199 Html(
6200 template
6201 .render()
6202 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6203 )
6204 .into_response()
6205}
6206
6207fn format_number(n: u64) -> String {
6215 let s = n.to_string();
6216 let mut out = String::with_capacity(s.len() + s.len() / 3);
6217 let len = s.len();
6218 for (i, c) in s.chars().enumerate() {
6219 if i > 0 && (len - i).is_multiple_of(3) {
6220 out.push(',');
6221 }
6222 out.push(c);
6223 }
6224 out
6225}
6226
6227const fn badge_char_width(c: char) -> f64 {
6228 match c {
6229 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
6230 'm' | 'w' => 9.0,
6231 ' ' => 4.0,
6232 _ => 6.5,
6233 }
6234}
6235
6236#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6237fn badge_text_px(text: &str) -> u32 {
6238 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
6239}
6240
6241fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
6242 let lw = badge_text_px(label) + 20;
6243 let rw = badge_text_px(value) + 20;
6244 let total = lw + rw;
6245 let lx = lw / 2;
6246 let rx = lw + rw / 2;
6247 let le = escape_html(label);
6248 let ve = escape_html(value);
6249 let ce = escape_html(color);
6250 format!(
6251 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
6252 <rect width="{total}" height="20" fill="#555"/>
6253 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
6254 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
6255 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
6256 <text x="{lx}" y="13">{le}</text>
6257 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
6258 <text x="{rx}" y="13">{ve}</text>
6259 </g>
6260</svg>"##
6261 )
6262}
6263
6264#[derive(Deserialize)]
6265struct BadgeQuery {
6266 label: Option<String>,
6267 color: Option<String>,
6268}
6269
6270async fn badge_handler(
6271 State(state): State<AppState>,
6272 AxumPath(metric): AxumPath<String>,
6273 Query(query): Query<BadgeQuery>,
6274) -> Response {
6275 let entry = {
6276 let reg = state.registry.lock().await;
6277 reg.entries.first().cloned()
6278 };
6279
6280 let Some(entry) = entry else {
6281 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
6282 return (
6283 [
6284 (header::CONTENT_TYPE, "image/svg+xml"),
6285 (header::CACHE_CONTROL, "no-cache, max-age=0"),
6286 ],
6287 svg,
6288 )
6289 .into_response();
6290 };
6291
6292 let (default_label, value, default_color) = match metric.as_str() {
6293 "code-lines" => (
6294 "code lines",
6295 format_number(entry.summary.code_lines),
6296 "#4a78ee",
6297 ),
6298 "files" => (
6299 "files analyzed",
6300 format_number(entry.summary.files_analyzed),
6301 "#4a9862",
6302 ),
6303 "comment-lines" => (
6304 "comment lines",
6305 format_number(entry.summary.comment_lines),
6306 "#b35428",
6307 ),
6308 "blank-lines" => (
6309 "blank lines",
6310 format_number(entry.summary.blank_lines),
6311 "#7a5db0",
6312 ),
6313 _ => return StatusCode::NOT_FOUND.into_response(),
6314 };
6315
6316 let label = query.label.as_deref().unwrap_or(default_label);
6317 let color = query.color.as_deref().unwrap_or(default_color);
6318 let svg = render_badge_svg(label, &value, color);
6319
6320 (
6321 [
6322 (header::CONTENT_TYPE, "image/svg+xml"),
6323 (header::CACHE_CONTROL, "no-cache, max-age=0"),
6324 ],
6325 svg,
6326 )
6327 .into_response()
6328}
6329
6330#[derive(Serialize)]
6338struct ApiCoverageBlock {
6339 lines_found: u64,
6340 lines_hit: u64,
6341 line_pct: f64,
6342 functions_found: u64,
6343 functions_hit: u64,
6344 function_pct: f64,
6345 branches_found: u64,
6346 branches_hit: u64,
6347 branch_pct: f64,
6348}
6349
6350#[derive(Serialize)]
6351struct ApiMetricsResponse {
6352 run_id: String,
6353 timestamp: String,
6354 project: String,
6355 summary: ApiSummaryPayload,
6356 languages: Vec<ApiLanguageRow>,
6357 #[serde(skip_serializing_if = "Option::is_none")]
6358 coverage: Option<ApiCoverageBlock>,
6359}
6360
6361#[derive(Serialize)]
6362struct ApiSummaryPayload {
6363 files_analyzed: u64,
6364 files_skipped: u64,
6365 code_lines: u64,
6366 comment_lines: u64,
6367 blank_lines: u64,
6368 total_physical_lines: u64,
6369 functions: u64,
6370 classes: u64,
6371 variables: u64,
6372 imports: u64,
6373}
6374
6375#[derive(Serialize)]
6376struct ApiLanguageRow {
6377 name: String,
6378 files: u64,
6379 code_lines: u64,
6380 comment_lines: u64,
6381 blank_lines: u64,
6382 functions: u64,
6383 classes: u64,
6384 variables: u64,
6385 imports: u64,
6386}
6387
6388async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
6389 let entry = {
6390 let reg = state.registry.lock().await;
6391 reg.entries.first().cloned()
6392 };
6393 entry.map_or_else(
6394 || error::not_found("no scans recorded yet"),
6395 |e| build_metrics_response(&e),
6396 )
6397}
6398
6399async fn api_metrics_run_handler(
6400 State(state): State<AppState>,
6401 AxumPath(run_id): AxumPath<String>,
6402) -> Response {
6403 let entry = {
6404 let reg = state.registry.lock().await;
6405 reg.find_by_run_id(&run_id).cloned()
6406 };
6407 entry.map_or_else(
6408 || error::not_found("run not found"),
6409 |e| build_metrics_response(&e),
6410 )
6411}
6412
6413fn build_metrics_response(entry: &RegistryEntry) -> Response {
6414 let languages: Vec<ApiLanguageRow> = entry
6415 .json_path
6416 .as_ref()
6417 .and_then(|p| read_json(p).ok())
6418 .map(|run| {
6419 run.totals_by_language
6420 .iter()
6421 .map(|l| ApiLanguageRow {
6422 name: l.language.display_name().to_string(),
6423 files: l.files,
6424 code_lines: l.code_lines,
6425 comment_lines: l.comment_lines,
6426 blank_lines: l.blank_lines,
6427 functions: l.functions,
6428 classes: l.classes,
6429 variables: l.variables,
6430 imports: l.imports,
6431 })
6432 .collect()
6433 })
6434 .unwrap_or_default();
6435
6436 let s = &entry.summary;
6437 let coverage = if s.coverage_lines_found > 0 {
6438 let pct = |hit: u64, found: u64| -> f64 {
6439 if found == 0 {
6440 0.0
6441 } else {
6442 #[allow(clippy::cast_precision_loss)]
6443 let v = (hit as f64 / found as f64) * 100.0;
6444 (v * 10.0).round() / 10.0
6445 }
6446 };
6447 Some(ApiCoverageBlock {
6448 lines_found: s.coverage_lines_found,
6449 lines_hit: s.coverage_lines_hit,
6450 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
6451 functions_found: s.coverage_functions_found,
6452 functions_hit: s.coverage_functions_hit,
6453 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
6454 branches_found: s.coverage_branches_found,
6455 branches_hit: s.coverage_branches_hit,
6456 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
6457 })
6458 } else {
6459 None
6460 };
6461 Json(ApiMetricsResponse {
6462 run_id: entry.run_id.clone(),
6463 timestamp: entry.timestamp_utc.to_rfc3339(),
6464 project: entry.project_label.clone(),
6465 summary: ApiSummaryPayload {
6466 files_analyzed: s.files_analyzed,
6467 files_skipped: s.files_skipped,
6468 code_lines: s.code_lines,
6469 comment_lines: s.comment_lines,
6470 blank_lines: s.blank_lines,
6471 total_physical_lines: s.total_physical_lines,
6472 functions: s.functions,
6473 classes: s.classes,
6474 variables: s.variables,
6475 imports: s.imports,
6476 },
6477 languages,
6478 coverage,
6479 })
6480 .into_response()
6481}
6482
6483#[derive(Deserialize)]
6490struct ProjectHistoryQuery {
6491 path: Option<String>,
6492}
6493
6494#[derive(Serialize)]
6495struct ProjectHistoryResponse {
6496 scan_count: usize,
6497 last_scan_id: Option<String>,
6498 last_scan_timestamp: Option<String>,
6499 last_scan_code_lines: Option<u64>,
6500 last_git_branch: Option<String>,
6501 last_git_commit: Option<String>,
6502}
6503
6504fn entry_matches_project(
6507 entry: &RegistryEntry,
6508 root_str: &str,
6509 upload_root: &str,
6510 upload_name_suffix: Option<&str>,
6511) -> bool {
6512 if entry.input_roots.iter().any(|r| r == root_str) {
6513 return true;
6514 }
6515 if let Some(suffix) = upload_name_suffix {
6516 return entry
6517 .input_roots
6518 .iter()
6519 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
6520 }
6521 false
6522}
6523
6524async fn project_history_handler(
6525 State(state): State<AppState>,
6526 Query(query): Query<ProjectHistoryQuery>,
6527) -> Response {
6528 let path = query.path.unwrap_or_default();
6529 let resolved = resolve_input_path(&path);
6530 let root_str = resolved.to_string_lossy().replace('\\', "/");
6531
6532 let upload_root = std::env::temp_dir()
6537 .join("oxide-sloc-uploads")
6538 .to_string_lossy()
6539 .replace('\\', "/");
6540 let upload_name_suffix: Option<String> =
6541 if state.server_mode && root_str.starts_with(&upload_root) {
6542 resolved
6543 .file_name()
6544 .and_then(|n| n.to_str())
6545 .map(|name| format!("/{name}"))
6546 } else {
6547 None
6548 };
6549 let suffix_ref = upload_name_suffix.as_deref();
6550
6551 let entries: Vec<_> = {
6552 let reg = state.registry.lock().await;
6553 reg.entries
6554 .iter()
6555 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
6556 .cloned()
6557 .collect()
6558 };
6559 let scan_count = entries.len();
6560 let last = entries.first();
6561 let last_scan_id = last.map(|e| e.run_id.clone());
6562 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
6563 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
6564 let last_git_branch = last.and_then(|e| e.git_branch.clone());
6565 let last_git_commit = last.and_then(|e| e.git_commit.clone());
6566
6567 Json(ProjectHistoryResponse {
6568 scan_count,
6569 last_scan_id,
6570 last_scan_timestamp,
6571 last_scan_code_lines,
6572 last_git_branch,
6573 last_git_commit,
6574 })
6575 .into_response()
6576}
6577
6578#[derive(Deserialize)]
6585struct MetricsHistoryQuery {
6586 root: Option<String>,
6587 limit: Option<usize>,
6588 submodule: Option<String>,
6591}
6592
6593#[derive(Serialize)]
6594struct MetricsSubmoduleLink {
6595 name: String,
6596 url: String,
6597}
6598
6599#[derive(Serialize)]
6600struct MetricsHistoryEntry {
6601 run_id: String,
6602 run_id_short: String,
6603 timestamp: String,
6604 commit: Option<String>,
6605 branch: Option<String>,
6606 tags: Vec<String>,
6607 nearest_tag: Option<String>,
6608 code_lines: u64,
6609 comment_lines: u64,
6610 blank_lines: u64,
6611 physical_lines: u64,
6612 files_analyzed: u64,
6613 files_skipped: u64,
6614 test_count: u64,
6615 project_label: String,
6616 html_url: Option<String>,
6617 has_pdf: bool,
6618 submodule_links: Vec<MetricsSubmoduleLink>,
6619 #[serde(skip_serializing_if = "Option::is_none")]
6621 coverage_line_pct: Option<f64>,
6622}
6623
6624fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
6625 let mut links: Vec<MetricsSubmoduleLink> = vec![];
6626 let sub_dir = e
6627 .html_path
6628 .as_ref()
6629 .and_then(|p| p.parent())
6630 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6631 let Some(dir) = sub_dir else { return links };
6632 let Ok(rd) = std::fs::read_dir(dir) else {
6633 return links;
6634 };
6635 for entry_res in rd.flatten() {
6636 let fname = entry_res.file_name();
6637 let fname_str = fname.to_string_lossy();
6638 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6639 let stem = &fname_str[..fname_str.len() - 5];
6640 let display = stem[4..].replace('-', " ");
6641 links.push(MetricsSubmoduleLink {
6642 name: display,
6643 url: format!("/runs/{stem}/{}", e.run_id),
6644 });
6645 }
6646 }
6647 links.sort_by(|a, b| a.name.cmp(&b.name));
6648 links
6649}
6650
6651fn apply_submodule_filter(
6652 base: MetricsHistoryEntry,
6653 filter: &str,
6654 e: &sloc_core::history::RegistryEntry,
6655) -> Option<MetricsHistoryEntry> {
6656 let json_path = e.json_path.as_ref()?;
6657 let json_str = std::fs::read_to_string(json_path).ok()?;
6658 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
6659 let sub = run
6660 .submodule_summaries
6661 .iter()
6662 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
6663 let safe = sanitize_project_label(&sub.name);
6664 let artifact_key = format!("sub_{safe}");
6665 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
6666 || base.html_url.clone(),
6667 |run_dir| {
6668 let sub_path = run_dir.join(format!("{artifact_key}.html"));
6669 if sub_path.exists() {
6670 Some(format!("/runs/{artifact_key}/{}", e.run_id))
6671 } else {
6672 base.html_url.clone()
6673 }
6674 },
6675 );
6676 Some(MetricsHistoryEntry {
6677 code_lines: sub.code_lines,
6678 comment_lines: sub.comment_lines,
6679 blank_lines: sub.blank_lines,
6680 physical_lines: sub.total_physical_lines,
6681 files_analyzed: sub.files_analyzed,
6682 html_url: sub_html_url,
6683 has_pdf: false,
6684 submodule_links: vec![],
6685 ..base
6686 })
6687}
6688
6689#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
6691 State(state): State<AppState>,
6692 Query(query): Query<MetricsHistoryQuery>,
6693) -> Response {
6694 let limit = query.limit.unwrap_or(50).min(500);
6695 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
6696
6697 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
6698 let reg = state.registry.lock().await;
6699 reg.entries
6700 .iter()
6701 .filter(|e| {
6702 query.root.as_ref().is_none_or(|root| {
6703 let resolved = resolve_input_path(root);
6704 let root_str = resolved.to_string_lossy().replace('\\', "/");
6705 e.input_roots.iter().any(|r| r == &root_str)
6706 })
6707 })
6708 .take(limit)
6709 .cloned()
6710 .collect()
6711 };
6712
6713 let entries: Vec<MetricsHistoryEntry> = candidate_entries
6714 .into_iter()
6715 .filter_map(|e| {
6716 let tags = e
6717 .git_tags
6718 .as_deref()
6719 .map(|s| {
6720 s.split(',')
6721 .map(|t| t.trim().to_string())
6722 .filter(|t| !t.is_empty())
6723 .collect()
6724 })
6725 .unwrap_or_default();
6726 let html_url = e
6727 .html_path
6728 .as_ref()
6729 .filter(|p| p.exists())
6730 .map(|_| format!("/runs/html/{}", e.run_id));
6731 let nearest_tag = e.git_nearest_tag.clone();
6732 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
6733 let run_id_short: String = e
6734 .run_id
6735 .split('-')
6736 .next_back()
6737 .unwrap_or(&e.run_id)
6738 .chars()
6739 .take(7)
6740 .collect();
6741 let submodule_links = build_entry_submodule_links(&e);
6742 #[allow(clippy::cast_precision_loss)]
6743 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
6744 let pct = (e.summary.coverage_lines_hit as f64
6745 / e.summary.coverage_lines_found as f64)
6746 * 100.0;
6747 Some((pct * 10.0).round() / 10.0)
6748 } else {
6749 None
6750 };
6751 let base = MetricsHistoryEntry {
6752 run_id: e.run_id.clone(),
6753 run_id_short,
6754 timestamp: e.timestamp_utc.to_rfc3339(),
6755 commit: e.git_commit.clone(),
6756 branch: e.git_branch.clone(),
6757 tags,
6758 nearest_tag,
6759 code_lines: e.summary.code_lines,
6760 comment_lines: e.summary.comment_lines,
6761 blank_lines: e.summary.blank_lines,
6762 physical_lines: e.summary.total_physical_lines,
6763 files_analyzed: e.summary.files_analyzed,
6764 files_skipped: e.summary.files_skipped,
6765 test_count: e.summary.test_count,
6766 project_label: e.project_label.clone(),
6767 html_url,
6768 has_pdf,
6769 submodule_links,
6770 coverage_line_pct,
6771 };
6772 if let Some(ref filter) = submodule_filter {
6773 apply_submodule_filter(base, filter, &e)
6774 } else {
6775 Some(base)
6776 }
6777 })
6778 .collect();
6779
6780 Json(entries).into_response()
6781}
6782
6783#[derive(Deserialize)]
6787struct MetricsSubmodulesQuery {
6788 root: Option<String>,
6789}
6790
6791#[derive(Serialize)]
6792struct SubmoduleEntry {
6793 name: String,
6794 relative_path: String,
6795}
6796
6797async fn api_metrics_submodules_handler(
6798 State(state): State<AppState>,
6799 Query(query): Query<MetricsSubmodulesQuery>,
6800) -> Response {
6801 let json_paths: Vec<std::path::PathBuf> = {
6802 let reg = state.registry.lock().await;
6803 reg.entries
6804 .iter()
6805 .filter(|e| {
6806 query.root.as_ref().is_none_or(|root| {
6807 let resolved = resolve_input_path(root);
6808 let root_str = resolved.to_string_lossy().replace('\\', "/");
6809 e.input_roots.iter().any(|r| r == &root_str)
6810 })
6811 })
6812 .filter_map(|e| e.json_path.clone())
6813 .collect()
6814 };
6815
6816 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6817 let mut result: Vec<SubmoduleEntry> = Vec::new();
6818
6819 for path in &json_paths {
6820 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
6821 continue;
6822 };
6823 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
6824 continue;
6825 };
6826 for sub in &run.submodule_summaries {
6827 if seen.insert(sub.name.clone()) {
6828 result.push(SubmoduleEntry {
6829 name: sub.name.clone(),
6830 relative_path: sub.relative_path.clone(),
6831 });
6832 }
6833 }
6834 }
6835
6836 result.sort_by(|a, b| a.name.cmp(&b.name));
6837 Json(result).into_response()
6838}
6839
6840#[derive(Deserialize)]
6849struct IngestQuery {
6850 label: Option<String>,
6851}
6852
6853#[derive(Serialize)]
6854struct IngestResponse {
6855 run_id: String,
6856 view_url: String,
6857}
6858
6859async fn api_ingest_handler(
6860 State(state): State<AppState>,
6861 Query(q): Query<IngestQuery>,
6862 Json(run): Json<sloc_core::AnalysisRun>,
6863) -> Response {
6864 let label = q.label.unwrap_or_else(|| {
6865 run.input_roots
6866 .first()
6867 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
6868 });
6869
6870 let label_for_task = label.clone();
6871 let result = tokio::task::spawn_blocking(move || {
6872 let html = render_html(&run)?;
6873 let run_id = run.tool.run_id.clone();
6874 let run_id_safe = run_id.len() <= 128
6875 && !run_id.is_empty()
6876 && run_id
6877 .chars()
6878 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
6879 if !run_id_safe {
6880 anyhow::bail!(
6881 "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
6882 );
6883 }
6884 let project_label = sanitize_project_label(&label_for_task);
6885 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
6886 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
6887 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
6888 _ => project_label,
6889 };
6890 let (artifacts, _pending_pdf) = persist_run_artifacts(
6891 &run,
6892 &html,
6893 &output_dir,
6894 true,
6895 true,
6896 false,
6897 &label_for_task,
6898 &file_stem,
6899 RunResultContext::default(),
6900 )?;
6901 Ok::<_, anyhow::Error>((run_id, artifacts, run))
6902 })
6903 .await;
6904
6905 match result {
6906 Ok(Ok((run_id, artifacts, run))) => {
6907 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
6908 (
6909 StatusCode::CREATED,
6910 Json(IngestResponse {
6911 view_url: format!("/view-reports?run_id={run_id}"),
6912 run_id,
6913 }),
6914 )
6915 .into_response()
6916 }
6917 Ok(Err(e)) => error::internal(&format!("{e:#}")),
6918 Err(e) => error::internal(&format!("{e}")),
6919 }
6920}
6921
6922#[allow(clippy::too_many_lines)] async fn trend_report_handler(
6930 State(state): State<AppState>,
6931 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6932) -> Response {
6933 auto_scan_watched_dirs(&state).await;
6934
6935 let watched_dirs_list: Vec<String> = {
6936 let wd = state.watched_dirs.lock().await;
6937 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6938 };
6939
6940 let roots: Vec<String> = {
6942 let reg = state.registry.lock().await;
6943 let mut seen = std::collections::BTreeSet::new();
6944 reg.entries
6945 .iter()
6946 .flat_map(|e| e.input_roots.iter().cloned())
6947 .filter(|r| seen.insert(r.clone()))
6948 .collect()
6949 };
6950
6951 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
6952 let nonce = &csp_nonce;
6953 let version = env!("CARGO_PKG_VERSION");
6954
6955 let watched_dirs_html: String = if state.server_mode {
6959 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()
6960 } else {
6961 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
6962 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
6963 .to_string()
6964 } else {
6965 watched_dirs_list
6966 .iter()
6967 .fold(String::new(), |mut s, d| {
6968 use std::fmt::Write as _;
6969 let escaped =
6970 d.replace('&', "&").replace('"', """).replace('<', "<");
6971 write!(
6972 s,
6973 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>"#
6974 ).expect("write to String is infallible");
6975 s
6976 })
6977 };
6978 format!(
6979 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>"#
6980 )
6981 };
6982
6983 let html = format!(
6984 r##"<!doctype html>
6985<html lang="en">
6986<head>
6987 <meta charset="utf-8" />
6988 <meta name="viewport" content="width=device-width, initial-scale=1" />
6989 <title>OxideSLOC | Trend Reports</title>
6990 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6991 <style nonce="{nonce}">
6992 :root {{
6993 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
6994 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6995 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
6996 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6997 --info-bg:#eef3ff; --info-text:#4467d8;
6998 }}
6999 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7000 *{{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;}}
7001 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7002 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7003 .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;}}
7004 @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));}}}}
7005 .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);}}
7006 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7007 .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));}}
7008 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7009 .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;}}
7010 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7011 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7012 @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; }} }}
7013 .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;}}
7014 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7015 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7016 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7017 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7018 .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;}}
7019 .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;}}
7020 .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;}}
7021 .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;}}
7022 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7023 .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);}}
7024 .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;}}
7025 .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;}}
7026 .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;}}
7027 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7028 .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;}}
7029 .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);}}
7030 .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;}}
7031 .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;}}
7032 .tz-select:focus{{border-color:var(--oxide);}}
7033 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
7034 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7035 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7036 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7037 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
7038 .trend-title-block{{flex:1;min-width:0;}}
7039 .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;}}
7040 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
7041 .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;}}
7042 .chart-select:focus{{border-color:var(--accent);}}
7043 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7044 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7045 .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;}}
7046 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7047 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7048 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7049 .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);}}
7050 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7051 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7052 .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;}}
7053 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
7054 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
7055 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
7056 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7057 .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;}}
7058 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
7059 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
7060 .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);}}
7061 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
7062 .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;}}
7063 .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;}}
7064 .data-table tr:last-child td{{border-bottom:none;}}
7065 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
7066 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
7067 .table-wrap{{width:100%;overflow-x:auto;}}
7068 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
7069 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
7070 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
7071 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
7072 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
7073 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
7074 .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;}}
7075 .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;}}
7076 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
7077 .pagination-info{{font-size:13px;color:var(--muted);}}
7078 .pagination-btns{{display:flex;gap:6px;}}
7079 .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;}}
7080 .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;}}
7081 #scan-history-table col:nth-child(1){{width:155px;}}
7082 #scan-history-table col:nth-child(2){{width:240px;}}
7083 #scan-history-table col:nth-child(3){{width:82px;}}
7084 #scan-history-table col:nth-child(4){{width:82px;}}
7085 #scan-history-table col:nth-child(5){{width:90px;}}
7086 #scan-history-table col:nth-child(6){{width:90px;}}
7087 #scan-history-table col:nth-child(7){{width:88px;}}
7088 #scan-history-table col:nth-child(8){{width:150px;}}
7089 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
7090 .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;}}
7091 .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;}}
7092 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
7093 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
7094 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7095 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7096 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7097 .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;}}
7098 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7099 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7100 .watched-chip-rm:hover{{color:var(--oxide);}}
7101 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7102 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7103 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7104 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7105 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
7106 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
7107 a.run-link:hover{{text-decoration:underline;}}
7108 .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);}}
7109 .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);}}
7110 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
7111 .metric-num{{font-weight:700;color:var(--text);}}
7112 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
7113 .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;}}
7114 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
7115 .btn.primary:hover{{opacity:.9;}}
7116 .rpt-btn{{min-width:58px;justify-content:center;}}
7117 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
7118 .report-cell{{overflow:visible!important;white-space:normal!important;}}
7119 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
7120 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
7121 .submod-details summary::-webkit-details-marker{{display:none;}}
7122 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
7123 .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;}}
7124 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
7125 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
7126 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
7127 .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;}}
7128 .export-btn:hover{{background:var(--line);}}
7129 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
7130 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7131 .site-footer a{{color:var(--muted);}}
7132 .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;}}
7133 .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;}}
7134 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
7135 </style>
7136</head>
7137<body>
7138 <div class="background-watermarks" aria-hidden="true">
7139 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7140 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7141 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7142 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7143 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7144 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7145 </div>
7146 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7147 <div class="top-nav">
7148 <div class="top-nav-inner">
7149 <a class="brand" href="/">
7150 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7151 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
7152 </a>
7153 <div class="nav-right">
7154 <a class="nav-pill" href="/">Home</a>
7155 <div class="nav-dropdown">
7156 <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>
7157 <div class="nav-dropdown-menu">
7158 <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>
7159 </div>
7160 </div>
7161 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7162 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
7163 <div class="nav-dropdown">
7164 <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>
7165 <div class="nav-dropdown-menu">
7166 <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>
7167 </div>
7168 </div>
7169 <div class="server-status-wrap" id="server-status-wrap">
7170 <div class="nav-pill server-online-pill" id="server-status-pill">
7171 <span class="status-dot" id="status-dot"></span>
7172 <span id="server-status-label">Server</span>
7173 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
7174 </div>
7175 <div class="server-status-tip">
7176 OxideSLOC is running — accessible on your network.
7177 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
7178 </div>
7179 </div>
7180 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7181 <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>
7182 </button>
7183 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7184 <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>
7185 <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>
7186 </button>
7187 </div>
7188 </div>
7189 </div>
7190
7191 <div class="page">
7192 {watched_dirs_html}
7193 <div class="summary-strip" id="trend-stats"></div>
7194 <div class="panel">
7195 <div class="trend-header">
7196 <div class="trend-title-block">
7197 <h1>Trend Reports</h1>
7198 <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>
7199 <span class="chart-hint-inline">
7200 <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>
7201 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
7202 </span>
7203 </div>
7204 <div class="chart-actions">
7205 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
7206 <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>
7207 Clean up old runs
7208 </button>
7209 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
7210 <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>
7211 Export Excel
7212 </button>
7213 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
7214 <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>
7215 Export PNG
7216 </button>
7217 </div>
7218 </div>
7219
7220 <div class="controls-centered">
7221 <label>Project Root:
7222 <select class="chart-select" id="root-sel">
7223 <option value="">All projects</option>
7224 </select>
7225 </label>
7226 <label>Y Metric:
7227 <select class="chart-select" id="y-sel">
7228 <option value="code_lines">Code Lines</option>
7229 <option value="comment_lines">Comment Lines</option>
7230 <option value="blank_lines">Blank Lines</option>
7231 <option value="physical_lines">Physical Lines</option>
7232 <option value="files_analyzed">Files Analyzed</option>
7233 </select>
7234 </label>
7235 <label>X Axis:
7236 <select class="chart-select" id="x-sel">
7237 <option value="time">By Time</option>
7238 <option value="commit">By Commit</option>
7239 <option value="release">By Release</option>
7240 <option value="tag">Tagged Commits</option>
7241 </select>
7242 </label>
7243 <label id="submodule-label" style="display:none;">Submodule:
7244 <select class="chart-select" id="sub-sel">
7245 <option value="">All (project total)</option>
7246 </select>
7247 </label>
7248 <label>Chart Size:
7249 <select class="chart-select" id="scale-sel">
7250 <option value="0.75">Compact</option>
7251 <option value="1.2" selected>Normal</option>
7252 <option value="1.38">Large</option>
7253 </select>
7254 </label>
7255 </div>
7256
7257 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
7258 <div id="data-table-wrap" style="overflow-x:auto;"></div>
7259 </div>
7260 </div>
7261
7262 <script nonce="{nonce}">
7263 (function() {{
7264 // Theme persistence
7265 var b = document.body;
7266 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7267 var tgl = document.getElementById('theme-toggle');
7268 if (tgl) tgl.addEventListener('click', function() {{
7269 var d = b.classList.toggle('dark-theme');
7270 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7271 }});
7272
7273 // Watermark randomizer
7274 (function() {{
7275 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7276 if (!wms.length) return;
7277 var placed = [];
7278 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;}}
7279 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];}}
7280 var half=Math.floor(wms.length/2);
7281 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;}});
7282 }})();
7283
7284 // Code particles
7285 (function() {{
7286 var container = document.getElementById('code-particles');
7287 if (!container) return;
7288 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'];
7289 for (var i = 0; i < 38; i++) {{
7290 (function(idx) {{
7291 var el = document.createElement('span');
7292 el.className = 'code-particle';
7293 el.textContent = snippets[idx % snippets.length];
7294 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7295 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7296 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7297 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';
7298 container.appendChild(el);
7299 }})(i);
7300 }}
7301 }})();
7302
7303 // Watched folder picker
7304 (function() {{
7305 var btn = document.getElementById('add-watched-btn');
7306 if (!btn) return;
7307 btn.addEventListener('click', function() {{
7308 fetch('/pick-directory?kind=reports')
7309 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
7310 .then(function(data) {{
7311 if (!data.cancelled && data.selected_path) {{
7312 var form = document.createElement('form');
7313 form.method = 'POST';
7314 form.action = '/watched-dirs/add';
7315 var ri = document.createElement('input');
7316 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7317 var fi = document.createElement('input');
7318 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7319 form.appendChild(ri); form.appendChild(fi);
7320 document.body.appendChild(form);
7321 form.submit();
7322 }}
7323 }})
7324 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7325 }});
7326 }})();
7327
7328 // Settings / color-scheme modal
7329 (function() {{
7330 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'}}];
7331 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);}});}}
7332 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7333 var btn=document.getElementById('settings-btn');if(!btn)return;
7334 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7335 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>';
7336 document.body.appendChild(m);
7337 var g=document.getElementById('scheme-grid');
7338 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);}});
7339 var cl=document.getElementById('settings-close');
7340 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);
7341 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');}});
7342 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7343 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7344 }})();
7345 }})();
7346
7347 var ROOTS = {roots_json};
7348 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7349 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
7350 var allData = [];
7351
7352 // Populate root selector
7353 var rootSel = document.getElementById('root-sel');
7354 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
7355
7356 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 Math.round(v/1e3)+'K';return v.toLocaleString();}}
7357 function fmtFull(n){{return Number(n).toLocaleString();}}
7358 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
7359
7360 // Tooltip
7361 var tt = document.createElement('div');
7362 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);';
7363 document.body.appendChild(tt);
7364 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
7365 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';}}
7366 function hideTT(){{tt.style.display='none';}}
7367
7368 function statExact(compact, full){{
7369 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
7370 }}
7371 function statVal(n){{
7372 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
7373 }}
7374
7375 function updateStats(data){{
7376 var statsEl=document.getElementById('trend-stats');
7377 if(!statsEl)return;
7378 if(!data||!data.length){{statsEl.innerHTML='';return;}}
7379 var yKey=document.getElementById('y-sel').value;
7380 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7381 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7382 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
7383 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
7384 var absDelta=Math.abs(delta);
7385 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
7386 var deltaExact=statExact(deltaCompact,deltaFull);
7387 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
7388 statsEl.innerHTML=
7389 '<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>'+
7390 '<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>'+
7391 '<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>'+
7392 '<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>';
7393 }}
7394
7395 var subSel = document.getElementById('sub-sel');
7396 var subLabel = document.getElementById('submodule-label');
7397
7398 function populateSubmodules(root){{
7399 if(!subSel||!subLabel)return;
7400 while(subSel.options.length>1)subSel.remove(1);
7401 subSel.value='';
7402 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
7403 fetch(url)
7404 .then(function(r){{return r.json();}})
7405 .then(function(subs){{
7406 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
7407 subs.forEach(function(s){{
7408 var o=document.createElement('option');
7409 o.value=s.name;
7410 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
7411 subSel.appendChild(o);
7412 }});
7413 subLabel.style.display='';
7414 }})
7415 .catch(function(){{subLabel.style.display='none';}});
7416 }}
7417
7418 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
7419
7420 function loadAndRender(){{
7421 var root = rootSel.value;
7422 var sub = subSel ? subSel.value : '';
7423 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
7424 document.getElementById('data-table-wrap').innerHTML='';
7425 var url = '/api/metrics/history?limit=100'
7426 + (root ? '&root='+encodeURIComponent(root) : '')
7427 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
7428 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
7429 allData = data;
7430 render(data);
7431 updateStats(data);
7432 }}).catch(function(){{
7433 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>';
7434 }});
7435 }}
7436
7437 function render(data){{
7438 var yKey = document.getElementById('y-sel').value;
7439 var xMode = document.getElementById('x-sel').value;
7440
7441 // Filter for tag/release mode
7442 var pts = data;
7443 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
7444
7445 // Sort oldest-first for the line chart
7446 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7447
7448 var wrap = document.getElementById('chart-wrap');
7449 if(!pts.length){{
7450 var emptyMsg = (xMode === 'tag')
7451 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
7452 : 'No scan data found for the selected filters.';
7453 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
7454 renderTable([]);
7455 return;
7456 }}
7457
7458 var scaleEl=document.getElementById('scale-sel');
7459 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
7460 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;
7461 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
7462
7463 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7464
7465 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">';
7466 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>';
7467
7468 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
7469
7470 // Grid + Y axis ticks
7471 for(var ti=0;ti<=5;ti++){{
7472 var gy=PT+CH-Math.round(ti/5*CH);
7473 var gv=Math.round(ti/5*maxY);
7474 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
7475 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
7476 }}
7477
7478 // X axis labels (every N-th point to avoid crowding)
7479 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
7480 pts.forEach(function(d,i){{
7481 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7482 if(i%labelEvery===0||i===pts.length-1){{
7483 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)));
7484 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>';
7485 }}
7486 }});
7487
7488 // Axis label
7489 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
7490 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>';
7491 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>';
7492
7493 // Area fill + line path
7494 var pathD='';
7495 pts.forEach(function(d,i){{
7496 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7497 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7498 pathD+=(i===0?'M':'L')+x+','+y;
7499 }});
7500 if(pts.length>1){{
7501 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
7502 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
7503 }}
7504 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
7505
7506 // Data points (clickable) + permanent value labels
7507 var showLabels = pts.length <= 40;
7508 var labelEveryN = pts.length > 20 ? 2 : 1;
7509 pts.forEach(function(d,i){{
7510 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7511 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7512 var hasTags=d.tags&&d.tags.length>0;
7513 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
7514 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
7515 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+'"/>';
7516 if(showLabels && i%labelEveryN===0){{
7517 var lx=x, ly=y-r-5;
7518 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>';
7519 }}
7520 }});
7521
7522 svg+='</svg>';
7523 wrap.innerHTML=svg;
7524
7525 // Attach point tooltips
7526 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
7527 c.addEventListener('mouseover',function(e){{
7528 var d=pts[parseInt(this.dataset.idx)];
7529 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(''):'';
7530 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>':'';
7531 showTT(e,
7532 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
7533 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
7534 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
7535 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
7536 );
7537 this.setAttribute('r','8');
7538 }});
7539 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
7540 c.addEventListener('mousemove',moveTT);
7541 c.addEventListener('click',function(){{
7542 var d=pts[parseInt(this.dataset.idx)];
7543 if(d.html_url) window.open(d.html_url,'_blank');
7544 }});
7545 }});
7546
7547 renderTable(pts, yKey);
7548 }}
7549
7550 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
7551 var shProjFilter='', shBranchFilter='';
7552
7553 function fmtPST(isoStr){{
7554 if(!isoStr)return'';
7555 var d=new Date(isoStr);
7556 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
7557 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);}}
7558 function p(n){{return n<10?'0'+n:String(n);}}
7559 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++;}}}}
7560 var yr=d.getUTCFullYear();
7561 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
7562 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
7563 var isDST=d>=dstStart&&d<dstEnd;
7564 var off=isDST?-7*3600*1000:-8*3600*1000;
7565 var lbl=isDST?'PDT':'PST';
7566 var loc=new Date(d.getTime()+off);
7567 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
7568 }}
7569
7570 function getShRows(){{
7571 var proj=shProjFilter.toLowerCase().trim();
7572 var branch=shBranchFilter;
7573 return shData.filter(function(d){{
7574 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
7575 if(branch&&(d.branch||'')!==branch)return false;
7576 return true;
7577 }});
7578 }}
7579
7580 function renderShPage(){{
7581 var filtered=getShRows();
7582 if(shSortCol){{
7583 filtered.sort(function(a,b){{
7584 var va,vb;
7585 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
7586 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
7587 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
7588 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
7589 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
7590 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
7591 }});
7592 }}
7593 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
7594 shPage=Math.min(shPage,totalPages);
7595 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
7596 var visible=filtered.slice(start,end);
7597 var tbody=document.getElementById('sh-tbody');
7598 if(!tbody)return;
7599 tbody.innerHTML=visible.map(function(d){{
7600 var tsHtml=esc(fmtPST(d.timestamp));
7601 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>';
7602 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>';
7603 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
7604 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
7605 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
7606 var reportCell='';
7607 if(d.html_url){{
7608 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
7609 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>';}}
7610 reportCell+='</div>';
7611 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
7612 if(d.submodule_links&&d.submodule_links.length){{
7613 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
7614 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
7615 reportCell+='</div></details>';
7616 }}
7617 return '<tr>'
7618 +'<td>'+tsHtml+'</td>'
7619 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
7620 +'<td>'+runIdHtml+'</td>'
7621 +'<td>'+commitHtml+'</td>'
7622 +'<td>'+branchHtml+'</td>'
7623 +'<td>'+tags+'</td>'
7624 +'<td class="num">'+metricHtml+'</td>'
7625 +'<td class="report-cell">'+reportCell+'</td>'
7626 +'</tr>';
7627 }}).join('');
7628 var pgRange=document.getElementById('sh-pg-range');
7629 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
7630 var pgInfo=document.getElementById('sh-pg-info');
7631 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
7632 var pgBtns=document.getElementById('sh-pg-btns');
7633 if(pgBtns){{
7634 pgBtns.innerHTML='';
7635 function mkPgBtn(lbl,pg,active,disabled){{
7636 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
7637 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
7638 return b;
7639 }}
7640 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
7641 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
7642 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
7643 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
7644 }}
7645 }}
7646
7647 function wireTableBehavior(){{
7648 var pf=document.getElementById('sh-proj-filter');
7649 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
7650 var bf=document.getElementById('sh-branch-filter');
7651 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
7652 var rb=document.getElementById('sh-reset-btn');
7653 if(rb)rb.addEventListener('click',function(){{
7654 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
7655 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
7656 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
7657 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');}});
7658 renderShPage();
7659 }});
7660 var pps=document.getElementById('sh-per-page');
7661 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
7662 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
7663 ths.forEach(function(th){{
7664 th.addEventListener('click',function(e){{
7665 if(e.target.classList.contains('col-resize-handle'))return;
7666 var col=th.dataset.col;
7667 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
7668 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7669 th.classList.add('sort-'+shSortOrder);
7670 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
7671 shPage=1;renderShPage();
7672 }});
7673 }});
7674 var table=document.getElementById('scan-history-table');
7675 if(!table)return;
7676 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
7677 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
7678 allThs.forEach(function(th,i){{
7679 var handle=th.querySelector('.col-resize-handle');
7680 if(!handle||!cols[i])return;
7681 var startX,startW;
7682 handle.addEventListener('mousedown',function(e){{
7683 e.stopPropagation();e.preventDefault();
7684 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
7685 handle.classList.add('dragging');
7686 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
7687 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
7688 document.addEventListener('mousemove',onMove);
7689 document.addEventListener('mouseup',onUp);
7690 }});
7691 }});
7692 }}
7693
7694 function renderTable(pts, yKey){{
7695 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
7696 var wrap=document.getElementById('data-table-wrap');
7697 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
7698 var yLabel=Y_LABELS[yKey]||yKey||'';
7699 shData=pts.slice().reverse();
7700 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
7701 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
7702 var branches={{}};
7703 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
7704 var branchOpts='<option value="">All branches</option>';
7705 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
7706 wrap.innerHTML=
7707 '<div class="chart-section-header">SCAN HISTORY</div>'+
7708 '<div class="filter-row">'+
7709 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
7710 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
7711 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
7712 '</div>'+
7713 '<div class="table-wrap">'+
7714 '<table id="scan-history-table" class="data-table">'+
7715 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
7716 '<thead><tr id="sh-thead">'+
7717 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7718 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7719 '<th>Run ID<div class="col-resize-handle"></div></th>'+
7720 '<th>Commit<div class="col-resize-handle"></div></th>'+
7721 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7722 '<th>Tags<div class="col-resize-handle"></div></th>'+
7723 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7724 '<th>Report<div class="col-resize-handle"></div></th>'+
7725 '</tr></thead>'+
7726 '<tbody id="sh-tbody"></tbody>'+
7727 '</table>'+
7728 '</div>'+
7729 '<div class="pagination">'+
7730 '<span class="pagination-info" id="sh-pg-info"></span>'+
7731 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
7732 '<div style="display:flex;align-items:center;gap:8px;">'+
7733 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
7734 '<select class="filter-select" id="sh-per-page">'+
7735 '<option value="10">10 per page</option>'+
7736 '<option value="25" selected>25 per page</option>'+
7737 '<option value="50">50 per page</option>'+
7738 '<option value="100">100 per page</option>'+
7739 '</select>'+
7740 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
7741 '</div>'+
7742 '</div>';
7743 wireTableBehavior();
7744 renderShPage();
7745 }}
7746
7747 function exportXLSX(){{
7748 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
7749 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
7750 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
7751 var s1R=sorted.map(function(d){{
7752 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||''];
7753 }});
7754 var pm={{}};
7755 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
7756 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'];
7757 var s2R=Object.keys(pm).map(function(p){{
7758 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7759 var lat=sc[sc.length-1],fst=sc[0];
7760 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
7761 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);
7762 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];
7763 }});
7764 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
7765 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
7766 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
7767 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
7768 }}
7769
7770 function buildXLSX(sheets,chartRows,chartRows2){{
7771 function s2b(s){{return new TextEncoder().encode(s);}}
7772 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
7773 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;}}
7774 function crc32(d){{
7775 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;}}}}
7776 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
7777 }}
7778 function buildSheet(hdr,rows,drawRid,withCtrl){{
7779 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
7780 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
7781 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
7782 x+='<row r="1">';
7783 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
7784 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>';}}
7785 x+='</row>';
7786 rows.forEach(function(row,ri){{
7787 var rn=ri+2;
7788 x+='<row r="'+rn+'">';
7789 row.forEach(function(cell,ci){{
7790 var addr=col2l(ci+1)+rn;
7791 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
7792 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
7793 }});
7794 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>';}}
7795 x+='</row>';
7796 }});
7797 x+='</sheetData>';
7798 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>';}}
7799 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
7800 return x+'</worksheet>';
7801 }}
7802 function buildChartXML(rows){{
7803 var sn="'Scan History'";
7804 var nr=rows.length,er=nr+1;
7805 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'}}];
7806 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7807 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">';
7808 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7809 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7810 sd.forEach(function(s,i){{
7811 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7812 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>';
7813 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7814 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>';
7815 var dlp=(i===2)?'b':'t';
7816 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>';
7817 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7818 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7819 x+='</c:strCache></c:strRef></c:cat>';
7820 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+'"/>';
7821 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7822 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7823 }});
7824 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
7825 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>';
7826 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>';
7827 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7828 return x;
7829 }}
7830 function buildChartXML2(rows){{
7831 var sn="'By Project'";
7832 var nr=rows.length,er=nr+1;
7833 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'}}];
7834 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7835 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">';
7836 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7837 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7838 sd.forEach(function(s,i){{
7839 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7840 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>';
7841 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7842 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>';
7843 var dlp=(i===2)?'b':'t';
7844 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>';
7845 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7846 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7847 x+='</c:strCache></c:strRef></c:cat>';
7848 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+'"/>';
7849 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7850 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7851 }});
7852 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
7853 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>';
7854 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>';
7855 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7856 return x;
7857 }}
7858 function buildChartXML3(rows){{
7859 var sn="'Scan History'";
7860 var nr=rows.length,er=nr+1;
7861 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7862 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">';
7863 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
7864 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7865 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
7866 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>';
7867 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
7868 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>';
7869 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>';
7870 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7871 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7872 x+='</c:strCache></c:strRef></c:cat>';
7873 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+'"/>';
7874 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
7875 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7876 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
7877 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>';
7878 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>';
7879 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>';
7880 return x;
7881 }}
7882 var hasChart=!!(chartRows&&chartRows.length);
7883 var nr=hasChart?chartRows.length:0;
7884 var hasChart2=!!(chartRows2&&chartRows2.length);
7885 var nr2=hasChart2?chartRows2.length:0;
7886 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>';
7887 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"/>';
7888 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
7889 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"/>';}}
7890 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"/>';}}
7891 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
7892 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>';
7893 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
7894 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"/>';}});
7895 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
7896 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>';
7897 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
7898 wbx+='</sheets></workbook>';
7899 var files=[
7900 {{name:'[Content_Types].xml',data:s2b(ct)}},
7901 {{name:'_rels/.rels',data:s2b(dotrels)}},
7902 {{name:'xl/workbook.xml',data:s2b(wbx)}},
7903 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
7904 {{name:'xl/styles.xml',data:s2b(styl)}}
7905 ];
7906 // Chart embedded directly in Scan History (sheet1); By Project is plain
7907 sheets.forEach(function(s,i){{
7908 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)))}});
7909 }});
7910 if(hasChart){{
7911 var fromRow=nr+4,toRow=nr+24;
7912 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>')}});
7913 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7914 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">';
7915 drx+='<xdr:twoCellAnchor editAs="twoCell">';
7916 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>';
7917 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>';
7918 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7919 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7920 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7921 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7922 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
7923 var focRow=toRow+2,focRowEnd=toRow+22;
7924 drx+='<xdr:twoCellAnchor editAs="twoCell">';
7925 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>';
7926 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>';
7927 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7928 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7929 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7930 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
7931 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
7932 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
7933 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>')}});
7934 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
7935 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
7936 }}
7937 if(hasChart2){{
7938 var fromRow2=nr2+4,toRow2=nr2+24;
7939 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>')}});
7940 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7941 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">';
7942 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
7943 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>';
7944 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>';
7945 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7946 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7947 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7948 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7949 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
7950 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
7951 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>')}});
7952 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
7953 }}
7954 var parts=[],offsets=[],total=0;
7955 files.forEach(function(f){{
7956 offsets.push(total);
7957 var nb=s2b(f.name),crc=crc32(f.data);
7958 var h=new DataView(new ArrayBuffer(30+nb.length));
7959 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
7960 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
7961 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
7962 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
7963 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
7964 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
7965 total+=30+nb.length+f.data.length;
7966 }});
7967 var cdStart=total;
7968 files.forEach(function(f,fi){{
7969 var nb=s2b(f.name),crc=crc32(f.data);
7970 var cd=new DataView(new ArrayBuffer(46+nb.length));
7971 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
7972 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
7973 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
7974 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
7975 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
7976 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
7977 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
7978 }});
7979 var cdSz=total-cdStart;
7980 var eocd=new DataView(new ArrayBuffer(22));
7981 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
7982 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
7983 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
7984 parts.push(new Uint8Array(eocd.buffer));
7985 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
7986 var out=new Uint8Array(sz);var off=0;
7987 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
7988 return out.buffer;
7989 }}
7990
7991 function exportPNG(){{
7992 var svgEl=document.querySelector('#chart-wrap svg');
7993 if(!svgEl){{alert('No chart to export yet.');return;}}
7994 var svgStr=new XMLSerializer().serializeToString(svgEl);
7995 var vb=svgEl.viewBox.baseVal,scale=2;
7996 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
7997 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
7998 var url=URL.createObjectURL(blob);
7999 var img=new Image();
8000 img.onload=function(){{
8001 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
8002 var ctx=canvas.getContext('2d');
8003 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
8004 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
8005 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
8006 URL.revokeObjectURL(url);
8007 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
8008 }};
8009 img.src=url;
8010 }}
8011
8012 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
8013 var el=document.getElementById(id);
8014 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
8015 }});
8016 rootSel.addEventListener('change',function(){{
8017 populateSubmodules(rootSel.value);
8018 loadAndRender();
8019 }});
8020 if(subSel)subSel.addEventListener('change',loadAndRender);
8021
8022 var xlsxBtn=document.getElementById('export-xlsx-btn');
8023 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
8024 var pngBtn=document.getElementById('export-png-btn');
8025 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
8026
8027 // ── Clean-up modal ───────────────────────────────────────────────────────
8028 (function(){{
8029 var triggerBtn=document.getElementById('cleanup-runs-btn');
8030 if(!triggerBtn)return;
8031 var modal=document.createElement('div');
8032 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;';
8033 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);">'
8034 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
8035 +'<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>'
8036 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
8037 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
8038 +'<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;">'
8039 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
8040 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
8041 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
8042 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
8043 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
8044 +'</div></div>';
8045 document.body.appendChild(modal);
8046 triggerBtn.addEventListener('click',function(){{
8047 document.getElementById('cleanup-status').style.display='none';
8048 modal.style.display='flex';
8049 }});
8050 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
8051 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8052 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
8053 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
8054 var confirmBtn=this;
8055 confirmBtn.disabled=true;
8056 var status=document.getElementById('cleanup-status');
8057 status.style.display='block';
8058 status.style.background='#dbeafe';status.style.color='#1e40af';
8059 status.textContent='Deleting\u2026';
8060 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
8061 .then(function(resp){{
8062 return resp.json().then(function(d){{
8063 if(resp.ok){{
8064 status.style.background='#dcfce7';status.style.color='#166534';
8065 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
8066 setTimeout(function(){{window.location.reload();}},1500);
8067 }}else{{
8068 status.style.background='#fee2e2';status.style.color='#991b1b';
8069 status.textContent='Error: '+(d.error||'Unexpected error');
8070 confirmBtn.disabled=false;
8071 }}
8072 }});
8073 }})
8074 .catch(function(e){{
8075 status.style.background='#fee2e2';status.style.color='#991b1b';
8076 status.textContent='Network error: '+String(e);
8077 confirmBtn.disabled=false;
8078 }});
8079 }});
8080 }})();
8081
8082 populateSubmodules(rootSel.value);
8083 loadAndRender();
8084
8085 (function randomizeWatermarks() {{
8086 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8087 if (!wms.length) return;
8088 var placed = [];
8089 function tooClose(top, left) {{
8090 for (var i = 0; i < placed.length; i++) {{
8091 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
8092 if (dt < 16 && dl < 12) return true;
8093 }}
8094 return false;
8095 }}
8096 function pick(leftBand) {{
8097 for (var attempt = 0; attempt < 50; attempt++) {{
8098 var top = Math.random() * 88 + 2;
8099 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8100 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
8101 }}
8102 var top = Math.random() * 88 + 2;
8103 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8104 placed.push([top, left]); return [top, left];
8105 }}
8106 var half = Math.floor(wms.length / 2);
8107 wms.forEach(function (img, i) {{
8108 var pos = pick(i < half);
8109 var size = Math.floor(Math.random() * 100 + 120);
8110 var rot = (Math.random() * 360).toFixed(1);
8111 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
8112 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;
8113 }});
8114 }})();
8115 (function spawnCodeParticles() {{
8116 var container = document.getElementById('code-particles');
8117 if (!container) return;
8118 var snippets = [
8119 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
8120 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
8121 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
8122 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
8123 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
8124 ];
8125 var count = 38;
8126 for (var i = 0; i < count; i++) {{
8127 (function(idx) {{
8128 var el = document.createElement('span');
8129 el.className = 'code-particle';
8130 el.textContent = snippets[idx % snippets.length];
8131 var left = Math.random() * 94 + 2;
8132 var top = Math.random() * 88 + 6;
8133 var dur = (Math.random() * 10 + 9).toFixed(1);
8134 var delay = (Math.random() * 18).toFixed(1);
8135 var rot = (Math.random() * 26 - 13).toFixed(1);
8136 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8137 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
8138 container.appendChild(el);
8139 }})(i);
8140 }}
8141 }})();
8142 </script>
8143 <footer class="site-footer">
8144 local code analysis - metrics, history and reports
8145 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
8146 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8147 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8148 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8149 · <a href="/api-docs" rel="noopener">REST API</a>
8150 </footer>
8151 <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>
8152</body>
8153</html>"##,
8154 );
8155
8156 Html(html).into_response()
8157}
8158
8159fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
8160 use std::collections::HashMap;
8161 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
8162 return vec![];
8163 }
8164 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8165 for rec in per_file_records {
8166 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8167 let e = totals.entry(lang.display_name().to_string()).or_default();
8168 e.0 += u64::from(cov.lines_found);
8169 e.1 += u64::from(cov.lines_hit);
8170 }
8171 }
8172 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
8174 .into_iter()
8175 .filter(|(_, (found, _))| *found > 0)
8176 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8177 .collect();
8178 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8179 pairs
8180 .iter()
8181 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
8182 .collect()
8183}
8184
8185fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
8186 let mut high = 0u64;
8187 let mut mid = 0u64;
8188 let mut low = 0u64;
8189 for rec in per_file_records {
8190 if let Some(cov) = &rec.coverage {
8191 if cov.lines_found == 0 {
8192 continue;
8193 }
8194 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
8195 if pct >= 80.0 {
8196 high += 1;
8197 } else if pct >= 50.0 {
8198 mid += 1;
8199 } else {
8200 low += 1;
8201 }
8202 }
8203 }
8204 (high, mid, low)
8205}
8206
8207fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
8208 let mut arr: Vec<serde_json::Value> = per_file_records
8209 .iter()
8210 .filter_map(|rec| {
8211 rec.coverage.as_ref().map(|cov| {
8212 let line_pct = if cov.lines_found > 0 {
8213 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
8214 / 10.0
8215 } else {
8216 0.0
8217 };
8218 let fn_pct = if cov.functions_found > 0 {
8219 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
8220 .round()
8221 / 10.0
8222 } else {
8223 -1.0
8224 };
8225 serde_json::json!({
8226 "rel": rec.relative_path,
8227 "lang": rec.language.map_or("?", |l| l.display_name()),
8228 "line_pct": line_pct,
8229 "fn_pct": fn_pct,
8230 "lhit": cov.lines_hit,
8231 "lfound": cov.lines_found,
8232 "fhit": cov.functions_hit,
8233 "ffound": cov.functions_found,
8234 })
8235 })
8236 })
8237 .collect();
8238 arr.sort_by(|a, b| {
8239 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
8240 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
8241 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
8242 });
8243 arr
8244}
8245
8246#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
8248 let mut langs: Vec<&sloc_core::LanguageSummary> = run
8249 .totals_by_language
8250 .iter()
8251 .filter(|l| l.test_count > 0)
8252 .collect();
8253 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8254 let lang_tests: Vec<serde_json::Value> = langs
8255 .iter()
8256 .map(|l| {
8257 let d = if l.code_lines > 0 {
8258 l.test_count as f64 / l.code_lines as f64 * 1000.0
8259 } else {
8260 0.0
8261 };
8262 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8263 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8264 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8265 })
8266 .collect();
8267 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
8268 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8269 let t = &run.summary_totals;
8270 let total_tests = t.test_count;
8271 let density = if t.code_lines > 0 {
8272 total_tests as f64 / t.code_lines as f64 * 1000.0
8273 } else {
8274 0.0
8275 };
8276 let most_tested = langs.first().map_or_else(
8277 || "\u{2014}".to_string(),
8278 |l| l.language.display_name().to_string(),
8279 );
8280 let test_files: u64 = run
8281 .per_file_records
8282 .iter()
8283 .filter(|f| f.raw_line_categories.test_count > 0)
8284 .count() as u64;
8285 let cov_line = if t.coverage_lines_found > 0 {
8286 format!(
8287 "{:.1}",
8288 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
8289 )
8290 } else {
8291 "0".to_string()
8292 };
8293 let cov_fn = if t.coverage_functions_found > 0 {
8294 format!(
8295 "{:.1}",
8296 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
8297 )
8298 } else {
8299 "0".to_string()
8300 };
8301 let cov_branch = if t.coverage_branches_found > 0 {
8302 format!(
8303 "{:.1}",
8304 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
8305 )
8306 } else {
8307 "0".to_string()
8308 };
8309 let has_cov = !cov_arr.is_empty();
8310 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
8311 serde_json::json!({
8312 "totals": {
8313 "test_count": total_tests,
8314 "assertions": t.test_assertion_count,
8315 "suites": t.test_suite_count,
8316 "test_files": test_files,
8317 "total_files": t.files_analyzed,
8318 "density_str": format!("{density:.1}"),
8319 "most_tested": most_tested,
8320 "langs_with_tests": langs.len(),
8321 "cov_line": cov_line,
8322 "cov_fn": cov_fn,
8323 "cov_branch": cov_branch,
8324 },
8325 "lang_tests": lang_tests,
8326 "cov": cov_arr,
8327 "cov_tiers": {"high": high, "mid": mid, "low": low},
8328 "file_cov": file_cov_arr,
8329 "has_coverage": has_cov,
8330 "submodules": {},
8331 })
8332}
8333
8334#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
8336 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
8337 .language_summaries
8338 .iter()
8339 .filter(|l| l.test_count > 0)
8340 .collect();
8341 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8342 let lang_tests: Vec<serde_json::Value> = langs
8343 .iter()
8344 .map(|l| {
8345 let d = if l.code_lines > 0 {
8346 l.test_count as f64 / l.code_lines as f64 * 1000.0
8347 } else {
8348 0.0
8349 };
8350 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8351 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8352 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8353 })
8354 .collect();
8355 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
8356 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
8357 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
8358 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
8359 let density = if sub.code_lines > 0 {
8360 total_tests as f64 / sub.code_lines as f64 * 1000.0
8361 } else {
8362 0.0
8363 };
8364 let most_tested = langs.first().map_or_else(
8365 || "\u{2014}".to_string(),
8366 |l| l.language.display_name().to_string(),
8367 );
8368 serde_json::json!({
8369 "totals": {
8370 "test_count": total_tests,
8371 "assertions": total_assertions,
8372 "suites": total_suites,
8373 "test_files": test_files_approx,
8374 "total_files": sub.files_analyzed,
8375 "density_str": format!("{density:.1}"),
8376 "most_tested": most_tested,
8377 "langs_with_tests": langs.len(),
8378 "cov_line": "0",
8379 "cov_fn": "0",
8380 "cov_branch": "0",
8381 },
8382 "lang_tests": lang_tests,
8383 "cov": [],
8384 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
8385 "has_coverage": false,
8386 })
8387}
8388
8389fn compute_cov_json_str(run: &AnalysisRun) -> String {
8390 use std::collections::HashMap;
8391 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8392 for rec in &run.per_file_records {
8393 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8394 let e = totals.entry(lang.display_name().to_string()).or_default();
8395 e.0 += u64::from(cov.lines_found);
8396 e.1 += u64::from(cov.lines_hit);
8397 }
8398 }
8399 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
8401 .into_iter()
8402 .filter(|(_, (found, _))| *found > 0)
8403 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8404 .collect();
8405 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8406 let parts: Vec<String> = pairs
8407 .iter()
8408 .map(|(lang, pct)| {
8409 let name = lang.replace('"', "\\\"");
8410 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
8411 })
8412 .collect();
8413 format!("[{}]", parts.join(","))
8414}
8415
8416fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
8417 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8418 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
8419}
8420
8421fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
8422 let mut entry = build_test_scope_entry(run);
8423 if !run.submodule_summaries.is_empty() {
8424 let subs: serde_json::Map<String, serde_json::Value> = run
8425 .submodule_summaries
8426 .iter()
8427 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
8428 .collect();
8429 entry["submodules"] = serde_json::Value::Object(subs);
8430 }
8431 entry
8432}
8433
8434fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
8435 let name = l.language.display_name().replace('"', "\\\"");
8436 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
8438 l.test_count as f64 / l.code_lines as f64 * 1000.0
8439 } else {
8440 0.0
8441 };
8442 format!(
8443 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
8444 name = name,
8445 t = l.test_count,
8446 a = l.test_assertion_count,
8447 s = l.test_suite_count,
8448 c = l.code_lines,
8449 d = density,
8450 f = l.files,
8451 )
8452}
8453
8454fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
8455 let Some(r) = run else {
8456 return "[]".to_string();
8457 };
8458 let mut langs: Vec<&sloc_core::LanguageSummary> = r
8459 .totals_by_language
8460 .iter()
8461 .filter(|l| l.test_count > 0)
8462 .collect();
8463 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8464 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
8465 format!("[{}]", parts.join(","))
8466}
8467
8468async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
8470 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
8471 scope_map.insert(
8472 "__all__".to_string(),
8473 latest_run.map_or_else(
8474 || {
8475 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
8476 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
8477 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
8478 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
8479 "has_coverage":false,"submodules":{}})
8480 },
8481 build_test_scope_entry,
8482 ),
8483 );
8484 let all_roots: Vec<String> = {
8485 let reg = state.registry.lock().await;
8486 let mut seen = std::collections::BTreeSet::new();
8487 reg.entries
8488 .iter()
8489 .flat_map(|e| e.input_roots.iter().cloned())
8490 .filter(|r| seen.insert(r.clone()))
8491 .collect()
8492 };
8493 for root in &all_roots {
8494 let json_path = {
8495 let reg = state.registry.lock().await;
8496 reg.entries
8497 .iter()
8498 .find(|e| e.input_roots.iter().any(|r| r == root))
8499 .and_then(|e| e.json_path.clone())
8500 };
8501 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
8502 let json_str = tokio::fs::read_to_string(&p).await.ok();
8503 json_str
8504 .as_deref()
8505 .and_then(|s| serde_json::from_str(s).ok())
8506 } else {
8507 None
8508 };
8509 if let Some(ref run) = run_for_root {
8510 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
8511 }
8512 }
8513 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
8514}
8515
8516#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
8520 State(state): State<AppState>,
8521 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8522) -> Response {
8523 auto_scan_watched_dirs(&state).await;
8524 let watched_dirs_list: Vec<String> = {
8525 let wd = state.watched_dirs.lock().await;
8526 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8527 };
8528 let latest_run: Option<AnalysisRun> = {
8529 let json_path = {
8530 let reg = state.registry.lock().await;
8531 reg.entries.first().and_then(|e| e.json_path.clone())
8532 };
8533 if let Some(p) = json_path {
8534 let json_str = tokio::fs::read_to_string(&p).await.ok();
8535 json_str
8536 .as_deref()
8537 .and_then(|s| serde_json::from_str(s).ok())
8538 } else {
8539 None
8540 }
8541 };
8542
8543 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
8545
8546 let cov_json: String = latest_run
8548 .as_ref()
8549 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8550 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
8551
8552 let _cov_tier_json: String = latest_run
8554 .as_ref()
8555 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8556 .map_or_else(
8557 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
8558 compute_cov_tier_json_str,
8559 );
8560
8561 let total_tests: u64 = latest_run
8562 .as_ref()
8563 .map_or(0, |r| r.summary_totals.test_count);
8564 let total_assertions: u64 = latest_run
8565 .as_ref()
8566 .map_or(0, |r| r.summary_totals.test_assertion_count);
8567 let total_suites: u64 = latest_run
8568 .as_ref()
8569 .map_or(0, |r| r.summary_totals.test_suite_count);
8570 let total_code: u64 = latest_run
8571 .as_ref()
8572 .map_or(0, |r| r.summary_totals.code_lines);
8573 let workspace_density: f64 = if total_code > 0 {
8574 total_tests as f64 / total_code as f64 * 1000.0
8575 } else {
8576 0.0
8577 };
8578 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
8579 r.totals_by_language
8580 .iter()
8581 .filter(|l| l.test_count > 0)
8582 .count()
8583 });
8584 let most_tested: String = latest_run
8585 .as_ref()
8586 .and_then(|r| {
8587 r.totals_by_language
8588 .iter()
8589 .filter(|l| l.test_count > 0)
8590 .max_by_key(|l| l.test_count)
8591 })
8592 .map_or_else(
8593 || "\u{2014}".to_string(),
8594 |l| l.language.display_name().to_string(),
8595 );
8596 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
8597 r.per_file_records
8598 .iter()
8599 .filter(|f| f.raw_line_categories.test_count > 0)
8600 .count() as u64
8601 });
8602 let total_files_analyzed: u64 = latest_run
8603 .as_ref()
8604 .map_or(0, |r| r.summary_totals.files_analyzed);
8605 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
8606
8607 let cov_line_pct_str: String = latest_run
8609 .as_ref()
8610 .filter(|r| r.summary_totals.coverage_lines_found > 0)
8611 .map_or_else(
8612 || "0".to_string(),
8613 |r| {
8614 format!(
8615 "{:.1}",
8616 r.summary_totals.coverage_lines_hit as f64
8617 / r.summary_totals.coverage_lines_found as f64
8618 * 100.0
8619 )
8620 },
8621 );
8622 let cov_fn_pct_str: String = latest_run
8623 .as_ref()
8624 .filter(|r| r.summary_totals.coverage_functions_found > 0)
8625 .map_or_else(
8626 || "0".to_string(),
8627 |r| {
8628 format!(
8629 "{:.1}",
8630 r.summary_totals.coverage_functions_hit as f64
8631 / r.summary_totals.coverage_functions_found as f64
8632 * 100.0
8633 )
8634 },
8635 );
8636 let cov_branch_pct_str: String = latest_run
8637 .as_ref()
8638 .filter(|r| r.summary_totals.coverage_branches_found > 0)
8639 .map_or_else(
8640 || "0".to_string(),
8641 |r| {
8642 format!(
8643 "{:.1}",
8644 r.summary_totals.coverage_branches_hit as f64
8645 / r.summary_totals.coverage_branches_found as f64
8646 * 100.0
8647 )
8648 },
8649 );
8650
8651 let cov_no_data_notice = if has_coverage {
8652 String::new()
8653 } else {
8654 String::from(
8655 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
8656<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>
8657<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
8658 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
8659 <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>
8660 <span style="color:var(--muted);font-size:12px;">·</span>
8661 <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>
8662 <span style="color:var(--muted);font-size:12px;">·</span>
8663 <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>
8664</div>
8665<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
8666</div>"#,
8667 )
8668 };
8669
8670 let workspace_density_str = format!("{workspace_density:.1}");
8671 let nonce = &csp_nonce;
8672 let version = env!("CARGO_PKG_VERSION");
8673
8674 let watched_dirs_html: String = if state.server_mode {
8677 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()
8678 } else {
8679 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
8680 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
8681 .to_string()
8682 } else {
8683 watched_dirs_list
8684 .iter()
8685 .fold(String::new(), |mut s, d| {
8686 use std::fmt::Write as _;
8687 let escaped =
8688 d.replace('&', "&").replace('"', """).replace('<', "<");
8689 write!(
8690 s,
8691 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>"#
8692 ).expect("write to String is infallible");
8693 s
8694 })
8695 };
8696 format!(
8697 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>"#
8698 )
8699 };
8700
8701 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
8703
8704 let html = format!(
8705 r#"<!doctype html>
8706<html lang="en">
8707<head>
8708 <meta charset="utf-8" />
8709 <meta name="viewport" content="width=device-width, initial-scale=1" />
8710 <title>OxideSLOC | Test Metrics</title>
8711 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8712 <style nonce="{nonce}">
8713 :root {{
8714 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8715 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8716 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
8717 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8718 --info-bg:#eef3ff; --info-text:#4467d8;
8719 }}
8720 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
8721 *{{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;}}
8722 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8723 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
8724 .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;}}
8725 @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));}}}}
8726 .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);}}
8727 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
8728 .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));}}
8729 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8730 .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;}}
8731 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
8732 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
8733 @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; }} }}
8734 .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;}}
8735 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8736 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8737 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8738 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
8739 .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;}}
8740 .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;}}
8741 .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;}}
8742 .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;}}
8743 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8744 .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);}}
8745 .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;}}
8746 .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;}}
8747 .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;}}
8748 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8749 .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;}}
8750 .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);}}
8751 .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;}}
8752 .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;}}
8753 .tz-select:focus{{border-color:var(--oxide);}}
8754 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8755 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
8756 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
8757 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
8758 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
8759 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
8760 .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;}}
8761 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
8762 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
8763 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
8764 .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;}}
8765 .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;}}
8766 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
8767 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
8768 .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);}}
8769 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
8770 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
8771 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
8772 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
8773 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
8774 .chart-canvas-wrap{{position:relative;height:280px;}}
8775 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8776 .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;}}
8777 .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;}}
8778 .data-table tr:last-child td{{border-bottom:none;}}
8779 .data-table tbody tr:hover td{{background:var(--surface-2);}}
8780 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
8781 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
8782 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
8783 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
8784 .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;}}
8785 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
8786 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
8787 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
8788 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
8789 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
8790 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
8791 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
8792 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
8793 .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;}}
8794 .chart-select:focus{{border-color:var(--accent);}}
8795 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
8796 .trend-canvas-wrap{{position:relative;height:260px;}}
8797 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8798 .site-footer a{{color:var(--muted);}}
8799 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
8800 .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;}}
8801 .btn:hover{{background:var(--surface-2);}}
8802 .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;}}
8803 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8804 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
8805 .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;}}
8806 .scope-sel:focus{{border-color:var(--accent);}}
8807 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
8808 .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;}}
8809 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
8810 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8811 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
8812 .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;}}
8813 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
8814 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
8815 .watched-chip-rm:hover{{color:var(--oxide);}}
8816 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
8817 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
8818 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
8819 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
8820 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
8821 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
8822 .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;}}
8823 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
8824 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
8825 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
8826 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
8827 .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;}}
8828 .cov-file-search:focus{{border-color:var(--accent);}}
8829 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
8830 .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;}}
8831 body.dark-theme .cov-file-search{{background:var(--surface);}}
8832 </style>
8833</head>
8834<body>
8835 <div class="background-watermarks" aria-hidden="true">
8836 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8837 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8838 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8839 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8840 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8841 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8842 </div>
8843 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8844 <div class="top-nav">
8845 <div class="top-nav-inner">
8846 <a class="brand" href="/">
8847 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8848 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
8849 </a>
8850 <div class="nav-right">
8851 <a class="nav-pill" href="/">Home</a>
8852 <div class="nav-dropdown">
8853 <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>
8854 <div class="nav-dropdown-menu">
8855 <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>
8856 </div>
8857 </div>
8858 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8859 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
8860 <div class="nav-dropdown">
8861 <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>
8862 <div class="nav-dropdown-menu">
8863 <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>
8864 </div>
8865 </div>
8866 <div class="server-status-wrap" id="server-status-wrap">
8867 <div class="nav-pill server-online-pill" id="server-status-pill">
8868 <span class="status-dot" id="status-dot"></span>
8869 <span id="server-status-label">Server</span>
8870 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8871 </div>
8872 <div class="server-status-tip">
8873 OxideSLOC is running — accessible on your network.
8874 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8875 </div>
8876 </div>
8877 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8878 <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>
8879 </button>
8880 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8881 <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>
8882 <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>
8883 </button>
8884 </div>
8885 </div>
8886 </div>
8887
8888 <div class="page">
8889 {watched_dirs_html}
8890 <div class="scope-bar">
8891 <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>
8892 <span class="scope-label">Scope</span>
8893 <div class="scope-sel-wrap">
8894 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
8895 <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);">
8896 <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>
8897 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
8898 </div>
8899 </div>
8900 </div>
8901 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8902 <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>
8903 <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>
8904 <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>
8905 <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>
8906 </div>
8907 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8908 <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>
8909 <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>
8910 <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>
8911 <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>
8912 </div>
8913
8914 <div class="panel">
8915 <h1>Test Metrics</h1>
8916 <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>
8917
8918 <div class="chart-row">
8919 <div class="chart-box">
8920 <div class="chart-box-title">Test Definitions by Language</div>
8921 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
8922 </div>
8923 <div class="chart-box">
8924 <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
8925 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
8926 </div>
8927 </div>
8928
8929 <div class="section-header">Language Breakdown</div>
8930 {cov_no_data_notice}
8931 <div style="overflow-x:auto;">
8932 <table class="data-table" id="lang-table">
8933 <thead><tr>
8934 <th>Language</th>
8935 <th class="num">Test Fns</th>
8936 <th class="num">Assertions</th>
8937 <th class="num">Suites</th>
8938 <th class="num">Code Lines</th>
8939 <th class="num">Files</th>
8940 <th class="num">Density / 1K</th>
8941 <th>Relative Density</th>
8942 </tr></thead>
8943 <tbody id="lang-tbody"></tbody>
8944 </table>
8945 </div>
8946 </div>
8947
8948 <div class="panel" id="cov-panel" style="display:none;">
8949 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
8950 <div class="cov-gauge-row" id="cov-gauges">
8951 <div class="cov-gauge-card">
8952 <div class="cov-gauge-label">Line Coverage</div>
8953 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
8954 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
8955 <div class="cov-gauge-sub">Lines hit / instrumented</div>
8956 </div>
8957 <div class="cov-gauge-card">
8958 <div class="cov-gauge-label">Function Coverage</div>
8959 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
8960 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
8961 <div class="cov-gauge-sub">Functions hit / found</div>
8962 </div>
8963 <div class="cov-gauge-card">
8964 <div class="cov-gauge-label">Branch Coverage</div>
8965 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
8966 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
8967 <div class="cov-gauge-sub">Branches hit / found</div>
8968 </div>
8969 </div>
8970 <div class="chart-row">
8971 <div class="chart-box">
8972 <div class="chart-box-title">Line Coverage % by Language</div>
8973 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
8974 </div>
8975 <div class="chart-box">
8976 <div class="chart-box-title">Coverage Tier Distribution</div>
8977 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
8978 </div>
8979 </div>
8980
8981 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
8982 <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>
8983 <div class="cov-file-toolbar">
8984 <div class="cov-filter-tabs" id="cov-filter-tabs">
8985 <button class="cov-tab active" data-tier="all">All</button>
8986 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
8987 <button class="cov-tab" data-tier="low">Low (<50%)</button>
8988 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
8989 <button class="cov-tab" data-tier="high">High (≥80%)</button>
8990 </div>
8991 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
8992 </div>
8993 <div style="overflow-x:auto;">
8994 <table class="data-table" id="cov-file-table">
8995 <thead><tr>
8996 <th>File</th>
8997 <th>Lang</th>
8998 <th class="num">Line %</th>
8999 <th class="num">Lines Hit / Found</th>
9000 <th class="num">Fn %</th>
9001 <th class="num">Fns Hit / Found</th>
9002 </tr></thead>
9003 <tbody id="cov-file-tbody"></tbody>
9004 </table>
9005 </div>
9006 <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>
9007 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
9008 </div>
9009
9010 <div class="panel">
9011 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
9012 <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
9013 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
9014 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
9015 </div>
9016 </div>
9017
9018 <footer class="site-footer">
9019 local code analysis - metrics, history and reports
9020 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
9021 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9022 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9023 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9024 · <a href="/api-docs" rel="noopener">REST API</a>
9025 </footer>
9026
9027 <script nonce="{nonce}">
9028 (function() {{
9029 // Theme
9030 var b = document.body;
9031 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
9032 var tgl = document.getElementById('theme-toggle');
9033 if (tgl) tgl.addEventListener('click', function() {{
9034 var d = b.classList.toggle('dark-theme');
9035 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
9036 }});
9037
9038 // Watermarks
9039 (function() {{
9040 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9041 if (!wms.length) return;
9042 var placed = [];
9043 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;}}
9044 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];}}
9045 var half=Math.floor(wms.length/2);
9046 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;}});
9047 }})();
9048
9049 // Code particles
9050 (function() {{
9051 var container = document.getElementById('code-particles');
9052 if (!container) return;
9053 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
9054 for (var i = 0; i < 36; i++) {{
9055 (function(idx) {{
9056 var el = document.createElement('span');
9057 el.className = 'code-particle';
9058 el.textContent = snippets[idx % snippets.length];
9059 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
9060 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
9061 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
9062 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';
9063 container.appendChild(el);
9064 }})(i);
9065 }}
9066 }})();
9067
9068 // Settings modal
9069 (function() {{
9070 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'}}];
9071 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);}});}}
9072 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
9073 var btn=document.getElementById('settings-btn');if(!btn)return;
9074 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9075 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>';
9076 document.body.appendChild(m);
9077 var g=document.getElementById('scheme-grid');
9078 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);}});
9079 var cl=document.getElementById('settings-close');
9080 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');}});
9081 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
9082 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
9083 }})();
9084
9085 // Watched folder picker
9086 (function() {{
9087 var btn = document.getElementById('add-watched-btn');
9088 if (!btn) return;
9089 btn.addEventListener('click', function() {{
9090 fetch('/pick-directory?kind=reports')
9091 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
9092 .then(function(data) {{
9093 if (!data.cancelled && data.selected_path) {{
9094 var form = document.createElement('form');
9095 form.method = 'POST';
9096 form.action = '/watched-dirs/add';
9097 var ri = document.createElement('input');
9098 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
9099 var fi = document.createElement('input');
9100 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
9101 form.appendChild(ri); form.appendChild(fi);
9102 document.body.appendChild(form);
9103 form.submit();
9104 }}
9105 }})
9106 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
9107 }});
9108 }})();
9109 }})();
9110 </script>
9111
9112 <script src="/static/chart.js" nonce="{nonce}"></script>
9113 <script nonce="{nonce}">
9114 (function() {{
9115 var SCOPE_DATA = {scope_data_json};
9116 var currentRoot = '__all__';
9117 var currentSub = '';
9118 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
9119 var ALL_CHARTS = [];
9120
9121 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 Math.round(v/1e3)+'K';return v.toLocaleString();}}
9122 function fmtFull(n){{return Number(n).toLocaleString();}}
9123 function isDark(){{return document.body.classList.contains('dark-theme');}}
9124 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
9125 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
9126 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
9127
9128 function getDataset() {{
9129 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9130 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
9131 return r;
9132 }}
9133 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
9134
9135 function renderTestCharts(D) {{
9136 testsChart = destroyChart(testsChart);
9137 densityChart = destroyChart(densityChart);
9138 if (!D || !D.length) return;
9139 var top15 = D.slice(0, 15);
9140 var canvas1 = document.getElementById('canvas-tests');
9141 if (canvas1) {{
9142 testsChart = new Chart(canvas1, {{
9143 type: 'bar',
9144 data: {{
9145 labels: top15.map(function(d){{ return d.lang; }}),
9146 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
9147 }},
9148 options: {{
9149 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
9150 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
9151 scales: {{
9152 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
9153 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
9154 }}
9155 }}
9156 }});
9157 ALL_CHARTS.push(testsChart);
9158 }}
9159 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
9160 var canvas2 = document.getElementById('canvas-density');
9161 if (canvas2) {{
9162 densityChart = new Chart(canvas2, {{
9163 type: 'bar',
9164 data: {{
9165 labels: topD.map(function(d){{ return d.lang; }}),
9166 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 }}]
9167 }},
9168 options: {{
9169 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
9170 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
9171 scales: {{
9172 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
9173 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
9174 }}
9175 }}
9176 }});
9177 ALL_CHARTS.push(densityChart);
9178 }}
9179 }}
9180
9181 function renderCovCharts(covD, tiers) {{
9182 covChart = destroyChart(covChart);
9183 tierChart = destroyChart(tierChart);
9184 var covCanvas = document.getElementById('canvas-cov');
9185 if (covCanvas && covD && covD.length) {{
9186 covChart = new Chart(covCanvas, {{
9187 type: 'bar',
9188 data: {{
9189 labels: covD.map(function(d){{ return d.lang; }}),
9190 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 }}]
9191 }},
9192 options: {{
9193 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
9194 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
9195 scales: {{
9196 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
9197 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
9198 }}
9199 }}
9200 }});
9201 ALL_CHARTS.push(covChart);
9202 }}
9203 var tierCanvas = document.getElementById('canvas-cov-tiers');
9204 if (tierCanvas && tiers) {{
9205 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
9206 tierChart = new Chart(tierCanvas, {{
9207 type: 'doughnut',
9208 data: {{
9209 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
9210 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
9211 }},
9212 options: {{
9213 responsive: true, maintainAspectRatio: false, cutout: '62%',
9214 plugins: {{
9215 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
9216 tooltip: {{ callbacks: {{ label: function(ctx) {{
9217 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
9218 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
9219 }} }} }}
9220 }}
9221 }}
9222 }});
9223 ALL_CHARTS.push(tierChart);
9224 }}
9225 }}
9226
9227 function buildLangTable(D) {{
9228 var tbody = document.getElementById('lang-tbody');
9229 if (!tbody) return;
9230 if (!D || !D.length) {{
9231 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>';
9232 return;
9233 }}
9234 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
9235 tbody.innerHTML = D.map(function(d) {{
9236 var barW = Math.round(d.density / maxDensity * 120);
9237 return '<tr>' +
9238 '<td><strong>' + d.lang + '</strong></td>' +
9239 '<td class="num">' + fmt(d.tests) + '</td>' +
9240 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
9241 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
9242 '<td class="num">' + fmt(d.code) + '</td>' +
9243 '<td class="num">' + fmt(d.files) + '</td>' +
9244 '<td class="num">' + d.density.toFixed(2) + '</td>' +
9245 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
9246 '</tr>';
9247 }}).join('');
9248 }}
9249
9250 var covFileData = [];
9251 var covFileTier = 'all';
9252 var covFileSearch = '';
9253
9254 function pctBadge(pct) {{
9255 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
9256 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
9257 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
9258 }}
9259
9260 function buildCovFileTable() {{
9261 var tbody = document.getElementById('cov-file-tbody');
9262 var empty = document.getElementById('cov-file-empty');
9263 var count = document.getElementById('cov-file-count');
9264 if (!tbody) return;
9265 var srch = covFileSearch.toLowerCase();
9266 var filtered = covFileData.filter(function(f) {{
9267 if (covFileTier === 'zero' && f.line_pct > 0) return false;
9268 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
9269 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
9270 if (covFileTier === 'high' && f.line_pct < 80) return false;
9271 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
9272 return true;
9273 }});
9274 if (!filtered.length) {{
9275 tbody.innerHTML = '';
9276 if (empty) empty.style.display = '';
9277 if (count) count.textContent = '';
9278 return;
9279 }}
9280 if (empty) empty.style.display = 'none';
9281 var shown = Math.min(filtered.length, 500);
9282 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
9283 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
9284 var fnCol = f.fn_pct < 0
9285 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
9286 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
9287 return '<tr>' +
9288 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
9289 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
9290 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
9291 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
9292 fnCol +
9293 '</tr>';
9294 }}).join('');
9295 }}
9296
9297 (function() {{
9298 var tabs = document.getElementById('cov-filter-tabs');
9299 if (tabs) {{
9300 tabs.addEventListener('click', function(e) {{
9301 var btn = e.target.closest('.cov-tab');
9302 if (!btn) return;
9303 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
9304 btn.classList.add('active');
9305 covFileTier = btn.getAttribute('data-tier');
9306 buildCovFileTable();
9307 }});
9308 }}
9309 var srch = document.getElementById('cov-file-search');
9310 if (srch) {{
9311 srch.addEventListener('input', function() {{
9312 covFileSearch = this.value;
9313 buildCovFileTable();
9314 }});
9315 }}
9316 }})();
9317
9318 function updateCovGauges(t) {{
9319 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
9320 var el;
9321 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
9322 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
9323 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
9324 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
9325 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
9326 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
9327 }}
9328
9329 function applyScope() {{
9330 var d = getDataset();
9331 var t = d.totals;
9332 var el;
9333 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
9334 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
9335 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
9336 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
9337 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
9338 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
9339 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
9340 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
9341 renderTestCharts(d.lang_tests);
9342 buildLangTable(d.lang_tests);
9343 var covPanel = document.getElementById('cov-panel');
9344 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
9345 if (d.has_coverage) {{
9346 renderCovCharts(d.cov, d.cov_tiers);
9347 updateCovGauges(t);
9348 covFileData = d.file_cov || [];
9349 covFileTier = 'all';
9350 covFileSearch = '';
9351 var tabs = document.getElementById('cov-filter-tabs');
9352 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
9353 var srch = document.getElementById('cov-file-search');
9354 if (srch) srch.value = '';
9355 buildCovFileTable();
9356 }}
9357 loadTrend();
9358 }}
9359
9360 // Populate scope-root-sel from SCOPE_DATA keys
9361 (function() {{
9362 var sel = document.getElementById('scope-root-sel');
9363 if (!sel) return;
9364 Object.keys(SCOPE_DATA).forEach(function(k) {{
9365 if (k === '__all__') return;
9366 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
9367 }});
9368 }})();
9369
9370 document.getElementById('scope-root-sel').addEventListener('change', function() {{
9371 currentRoot = this.value;
9372 currentSub = '';
9373 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9374 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
9375 var subWrap = document.getElementById('scope-sub-wrap');
9376 var subSel = document.getElementById('scope-sub-sel');
9377 subSel.innerHTML = '<option value="">Entire project</option>';
9378 if (subNames.length) {{
9379 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
9380 subWrap.style.display = 'flex';
9381 }} else {{
9382 subWrap.style.display = 'none';
9383 }}
9384 applyScope();
9385 }});
9386
9387 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
9388 currentSub = this.value;
9389 applyScope();
9390 }});
9391
9392 function buildTrend(data) {{
9393 var trendCanvas = document.getElementById('canvas-trend');
9394 var trendEmpty = document.getElementById('trend-empty');
9395 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
9396 pts = pts.slice().reverse();
9397 if (!pts.length) {{
9398 if (trendCanvas) trendCanvas.style.display = 'none';
9399 if (trendEmpty) trendEmpty.style.display = '';
9400 return;
9401 }}
9402 if (trendCanvas) trendCanvas.style.display = '';
9403 if (trendEmpty) trendEmpty.style.display = 'none';
9404 trendChart = destroyChart(trendChart);
9405 if (!trendCanvas) return;
9406 trendChart = new Chart(trendCanvas, {{
9407 type: 'line',
9408 data: {{
9409 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
9410 datasets: [{{
9411 label: 'Test Definitions',
9412 data: pts.map(function(d){{ return d.test_count; }}),
9413 borderColor: '#C45C10',
9414 backgroundColor: 'rgba(196,92,16,0.10)',
9415 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
9416 pointRadius: 5, fill: true, tension: 0.3
9417 }}]
9418 }},
9419 options: {{
9420 responsive: true, maintainAspectRatio: false,
9421 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
9422 scales: {{
9423 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
9424 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
9425 }}
9426 }}
9427 }});
9428 ALL_CHARTS.push(trendChart);
9429 }}
9430
9431 function loadTrend() {{
9432 var url = '/api/metrics/history?limit=100';
9433 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
9434 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
9435 buildTrend(data);
9436 }}).catch(function(){{
9437 var trendEmpty = document.getElementById('trend-empty');
9438 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
9439 }});
9440 }}
9441
9442 // Re-render charts on theme toggle
9443 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
9444 setTimeout(function() {{
9445 ALL_CHARTS.forEach(function(c) {{
9446 if (c && c.options && c.options.scales) {{
9447 Object.values(c.options.scales).forEach(function(ax) {{
9448 if (ax.grid) ax.grid.color = clr();
9449 if (ax.ticks) ax.ticks.color = txtClr();
9450 }});
9451 c.update();
9452 }}
9453 }});
9454 }}, 80);
9455 }});
9456
9457 applyScope();
9458 }})();
9459 </script>
9460 <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>
9461</body>
9462</html>"#,
9463 );
9464 Html(html).into_response()
9465}
9466
9467#[derive(Deserialize)]
9474struct EmbedQuery {
9475 run_id: Option<String>,
9476 theme: Option<String>,
9477}
9478
9479async fn embed_handler(
9480 State(state): State<AppState>,
9481 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9482 Query(query): Query<EmbedQuery>,
9483) -> Response {
9484 let entry = {
9485 let reg = state.registry.lock().await;
9486 query.run_id.as_ref().map_or_else(
9487 || reg.entries.first().cloned(),
9488 |id| reg.find_by_run_id(id).cloned(),
9489 )
9490 };
9491
9492 let Some(entry) = entry else {
9493 return Html(
9494 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
9495 .to_string(),
9496 )
9497 .into_response();
9498 };
9499
9500 let dark = query.theme.as_deref() == Some("dark");
9501 let languages: Vec<(String, u64, u64)> = entry
9502 .json_path
9503 .as_ref()
9504 .and_then(|p| read_json(p).ok())
9505 .map(|run| {
9506 run.totals_by_language
9507 .iter()
9508 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
9509 .collect()
9510 })
9511 .unwrap_or_default();
9512
9513 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
9514}
9515
9516fn render_embed_widget(
9517 entry: &RegistryEntry,
9518 languages: &[(String, u64, u64)],
9519 dark: bool,
9520 csp_nonce: &str,
9521) -> String {
9522 let s = &entry.summary;
9523 let total = s.code_lines + s.comment_lines + s.blank_lines;
9524 let code_pct = s
9525 .code_lines
9526 .checked_mul(100)
9527 .and_then(|n| n.checked_div(total))
9528 .unwrap_or(0);
9529
9530 let (bg, fg, surface, muted, border) = if dark {
9531 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
9532 } else {
9533 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
9534 };
9535
9536 let mut lang_rows = String::new();
9537 for (name, files, code) in languages {
9538 write!(
9539 lang_rows,
9540 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
9541 escape_html(name),
9542 format_number(*files),
9543 format_number(*code),
9544 )
9545 .ok();
9546 }
9547
9548 let lang_table = if lang_rows.is_empty() {
9549 String::new()
9550 } else {
9551 format!(
9552 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
9553 )
9554 };
9555
9556 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
9557 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
9558 let project_esc = escape_html(&entry.project_label);
9559 let code_lines = format_number(s.code_lines);
9560 let comment_lines = format_number(s.comment_lines);
9561 let files = format_number(s.files_analyzed);
9562 let code_raw = s.code_lines;
9563 let comment_raw = s.comment_lines;
9564 let blank_raw = s.blank_lines;
9565
9566 format!(
9567 r#"<!doctype html>
9568<html lang="en">
9569<head>
9570 <meta charset="utf-8">
9571 <meta name="viewport" content="width=device-width,initial-scale=1">
9572 <title>OxideSLOC — {project_esc}</title>
9573 <script src="/static/chart.js"></script>
9574 <style nonce="{csp_nonce}">
9575 *{{box-sizing:border-box;margin:0;padding:0}}
9576 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
9577 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
9578 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
9579 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
9580 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
9581 .card .v{{font-size:18px;font-weight:700}}
9582 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
9583 .row{{display:flex;gap:12px;align-items:flex-start}}
9584 .pie{{width:120px;height:120px;flex-shrink:0}}
9585 .lt{{border-collapse:collapse;width:100%;flex:1}}
9586 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
9587 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
9588 .n{{text-align:right}}
9589 .footer{{margin-top:10px;color:{muted};font-size:10px}}
9590 </style>
9591</head>
9592<body>
9593 <h2>{project_esc}</h2>
9594 <div class="sub">{timestamp} · run {run_short}</div>
9595 <div class="cards">
9596 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
9597 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
9598 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
9599 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
9600 </div>
9601 <div class="row">
9602 <canvas class="pie" id="c"></canvas>
9603 {lang_table}
9604 </div>
9605 <div class="footer">oxide-sloc</div>
9606 <script nonce="{csp_nonce}">
9607 new Chart(document.getElementById('c'),{{
9608 type:'doughnut',
9609 data:{{
9610 labels:['Code','Comments','Blank'],
9611 datasets:[{{
9612 data:[{code_raw},{comment_raw},{blank_raw}],
9613 backgroundColor:['#4a78ee','#b35428','#aaa'],
9614 borderWidth:0
9615 }}]
9616 }},
9617 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
9618 }});
9619 </script>
9620</body>
9621</html>"#
9622 )
9623}
9624
9625#[allow(clippy::too_many_arguments)]
9626fn persist_run_artifacts(
9627 run: &sloc_core::AnalysisRun,
9628 report_html: &str,
9629 run_dir: &Path,
9630 generate_json: bool,
9631 generate_html: bool,
9632 generate_pdf: bool,
9633 report_title: &str,
9634 file_stem: &str,
9635 result_context: RunResultContext,
9636) -> Result<(RunArtifacts, PendingPdf)> {
9637 fs::create_dir_all(run_dir)
9638 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
9639
9640 let mut html_path = None;
9641 let mut pdf_path = None;
9642 let mut json_path = None;
9643 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
9644
9645 if generate_html {
9646 let path = run_dir.join(format!("report_{file_stem}.html"));
9647 fs::write(&path, report_html)
9648 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
9649 html_path = Some(path);
9650 }
9651
9652 if generate_json {
9653 let path = run_dir.join(format!("result_{file_stem}.json"));
9654 let json = serde_json::to_string_pretty(run)
9655 .context("failed to serialize analysis run to JSON")?;
9656 fs::write(&path, json)
9657 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
9658 json_path = Some(path);
9659 }
9660
9661 if generate_pdf {
9662 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
9663
9664 match write_pdf_from_run(run, &pdf_dest) {
9667 Ok(()) => {
9668 eprintln!(
9669 "[oxide-sloc][pdf] native PDF written to {}",
9670 pdf_dest.display()
9671 );
9672 pdf_path = Some(pdf_dest);
9673 }
9675 Err(native_err) => {
9676 eprintln!(
9677 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), \
9678 scheduling HTML→browser fallback"
9679 );
9680 let source_html_path = if let Some(existing) = html_path.as_ref() {
9681 existing.clone()
9682 } else {
9683 let temp_html = run_dir.join("_report_rendered.html");
9684 fs::write(&temp_html, report_html).with_context(|| {
9685 format!(
9686 "failed to write temporary HTML report to {}",
9687 temp_html.display()
9688 )
9689 })?;
9690 temp_html
9691 };
9692 let cleanup_src = !generate_html;
9693 pdf_path = Some(pdf_dest.clone());
9694 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
9695 }
9696 }
9697 }
9698
9699 let csv_path = {
9701 let path = run_dir.join(format!("report_{file_stem}.csv"));
9702 if let Err(e) = sloc_report::write_csv(run, &path) {
9703 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
9704 None
9705 } else {
9706 Some(path)
9707 }
9708 };
9709
9710 let xlsx_path = {
9711 let path = run_dir.join(format!("report_{file_stem}.xlsx"));
9712 if let Err(e) = sloc_report::write_xlsx(run, &path) {
9713 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
9714 None
9715 } else {
9716 Some(path)
9717 }
9718 };
9719
9720 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
9721
9722 Ok((
9723 RunArtifacts {
9724 output_dir: run_dir.to_path_buf(),
9725 html_path,
9726 pdf_path,
9727 json_path,
9728 csv_path,
9729 xlsx_path,
9730 scan_config_path,
9731 report_title: report_title.to_string(),
9732 result_context,
9733 },
9734 pending_pdf,
9735 ))
9736}
9737
9738fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
9741 let exact = dir.join("scan-config.json");
9742 if exact.exists() {
9743 return Some(exact);
9744 }
9745 fs::read_dir(dir).ok().and_then(|entries| {
9746 entries
9747 .filter_map(std::result::Result::ok)
9748 .find(|e| {
9749 let name = e.file_name();
9750 let name = name.to_string_lossy();
9751 name.starts_with("scan-config") && name.ends_with(".json")
9752 })
9753 .map(|e| e.path())
9754 })
9755}
9756
9757async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
9760 let toml_str = match toml::to_string_pretty(&state.base_config) {
9761 Ok(s) => s,
9762 Err(e) => {
9763 return (
9764 StatusCode::INTERNAL_SERVER_ERROR,
9765 format!("serialization error: {e}"),
9766 )
9767 .into_response();
9768 }
9769 };
9770 (
9771 [
9772 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
9773 (
9774 header::CONTENT_DISPOSITION,
9775 "attachment; filename=\".oxide-sloc.toml\"",
9776 ),
9777 ],
9778 toml_str,
9779 )
9780 .into_response()
9781}
9782
9783#[derive(Serialize)]
9784struct OkResponse {
9785 ok: bool,
9786}
9787
9788#[derive(Serialize)]
9789struct SaveProfileResponse {
9790 ok: bool,
9791 id: String,
9792}
9793
9794#[derive(Serialize)]
9795struct ProfileListResponse {
9796 profiles: Vec<ScanProfile>,
9797}
9798
9799#[derive(Serialize)]
9800struct ImportConfigResponse {
9801 ok: bool,
9802 config: sloc_config::AppConfig,
9803}
9804
9805#[derive(Deserialize)]
9806struct ImportConfigBody {
9807 toml: String,
9808}
9809
9810async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
9811 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
9812 Ok(config) => {
9813 if let Err(e) = config.validate() {
9814 return error::unprocessable_entity(&e.to_string());
9815 }
9816 Json(ImportConfigResponse { ok: true, config }).into_response()
9817 }
9818 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
9819 }
9820}
9821
9822async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
9825 let store = state.scan_profiles.lock().await;
9826 Json(ProfileListResponse {
9827 profiles: store.profiles.clone(),
9828 })
9829}
9830
9831#[derive(Deserialize)]
9832struct SaveScanProfileBody {
9833 name: String,
9834 params: serde_json::Value,
9835}
9836
9837async fn api_save_scan_profile(
9838 State(state): State<AppState>,
9839 Json(body): Json<SaveScanProfileBody>,
9840) -> impl IntoResponse {
9841 if body.name.trim().is_empty() {
9842 return error::bad_request("name must not be empty");
9843 }
9844
9845 let id = uuid::Uuid::new_v4().to_string();
9846 let profile = ScanProfile {
9847 id: id.clone(),
9848 name: body.name.trim().to_string(),
9849 created_at: chrono::Utc::now().to_rfc3339(),
9850 params: body.params,
9851 };
9852
9853 let mut store = state.scan_profiles.lock().await;
9854 store.profiles.push(profile);
9855 if let Err(e) = store.save(&state.scan_profiles_path) {
9856 tracing::warn!("failed to persist scan profiles: {e}");
9857 }
9858 drop(store);
9859
9860 (
9861 StatusCode::CREATED,
9862 Json(SaveProfileResponse { ok: true, id }),
9863 )
9864 .into_response()
9865}
9866
9867async fn api_delete_scan_profile(
9868 State(state): State<AppState>,
9869 AxumPath(id): AxumPath<String>,
9870) -> impl IntoResponse {
9871 let mut store = state.scan_profiles.lock().await;
9872 let before = store.profiles.len();
9873 store.profiles.retain(|p| p.id != id);
9874 if store.profiles.len() == before {
9875 drop(store);
9876 return error::not_found("profile not found");
9877 }
9878 if let Err(e) = store.save(&state.scan_profiles_path) {
9879 tracing::warn!("failed to persist scan profiles: {e}");
9880 }
9881 drop(store);
9882 Json(OkResponse { ok: true }).into_response()
9883}
9884
9885fn resolve_output_root(raw: Option<&str>) -> PathBuf {
9886 let value = raw.unwrap_or("out/web").trim();
9887 let path = if value.is_empty() {
9888 PathBuf::from("out/web")
9889 } else {
9890 PathBuf::from(value)
9891 };
9892
9893 if path.is_absolute() {
9894 path
9895 } else {
9896 workspace_root().join(path)
9897 }
9898}
9899
9900fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
9902 std::env::var("SLOC_GIT_CLONES_DIR")
9903 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
9904}
9905
9906pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
9909 let safe: String = repo_url
9910 .chars()
9911 .map(|c| {
9912 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
9913 c
9914 } else {
9915 '_'
9916 }
9917 })
9918 .take(80)
9919 .collect();
9920 clones_dir.join(safe)
9921}
9922
9923pub(crate) fn scan_path_to_artifacts(
9926 scan_path: &Path,
9927 base_config: &AppConfig,
9928 label: &str,
9929) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
9930 let mut config = base_config.clone();
9931 config.discovery.root_paths = vec![scan_path.to_path_buf()];
9932 label.clone_into(&mut config.reporting.report_title);
9933 let run = analyze(&config, "git", None, None)?;
9934 let html = render_html(&run)?;
9935 let run_id = run.tool.run_id.clone();
9936 let project_label = sanitize_project_label(label);
9937 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9938 let file_stem = {
9939 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
9940 if commit.is_empty() {
9941 project_label
9942 } else {
9943 format!("{project_label}_{commit}")
9944 }
9945 };
9946 let (artifacts, _pending_pdf) = persist_run_artifacts(
9947 &run,
9948 &html,
9949 &output_dir,
9950 true,
9951 true,
9952 false,
9953 label,
9954 &file_stem,
9955 RunResultContext::default(),
9956 )?;
9957 Ok((run_id, artifacts, run))
9958}
9959
9960async fn restart_poll_schedules(state: &AppState) {
9962 let store = state.schedules.lock().await;
9963 let poll_schedules: Vec<_> = store
9964 .schedules
9965 .iter()
9966 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
9967 .cloned()
9968 .collect();
9969 drop(store);
9970 for schedule in poll_schedules {
9971 let interval = schedule.interval_secs.unwrap_or(300);
9972 let st = state.clone();
9973 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
9974 }
9975}
9976
9977fn split_patterns(raw: Option<&str>) -> Vec<String> {
9978 raw.unwrap_or("")
9979 .lines()
9980 .flat_map(|line| line.split(','))
9981 .map(str::trim)
9982 .filter(|part| !part.is_empty())
9983 .map(ToOwned::to_owned)
9984 .collect()
9985}
9986
9987fn build_sub_run(
9988 parent: &AnalysisRun,
9989 sub: &sloc_core::SubmoduleSummary,
9990 parent_path: &str,
9991) -> AnalysisRun {
9992 let sub_files: Vec<_> = parent
9993 .per_file_records
9994 .iter()
9995 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9996 .cloned()
9997 .collect();
9998 let mut config = parent.effective_configuration.clone();
9999 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
10000 AnalysisRun {
10001 tool: parent.tool.clone(),
10002 environment: parent.environment.clone(),
10003 effective_configuration: config,
10004 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
10005 summary_totals: SummaryTotals {
10006 files_considered: sub.files_analyzed,
10007 files_analyzed: sub.files_analyzed,
10008 files_skipped: 0,
10009 total_physical_lines: sub.total_physical_lines,
10010 code_lines: sub.code_lines,
10011 comment_lines: sub.comment_lines,
10012 blank_lines: sub.blank_lines,
10013 mixed_lines_separate: 0,
10014 functions: 0,
10015 classes: 0,
10016 variables: 0,
10017 imports: 0,
10018 test_count: 0,
10019 test_assertion_count: 0,
10020 test_suite_count: 0,
10021 coverage_lines_found: 0,
10022 coverage_lines_hit: 0,
10023 coverage_functions_found: 0,
10024 coverage_functions_hit: 0,
10025 coverage_branches_found: 0,
10026 coverage_branches_hit: 0,
10027 },
10028 totals_by_language: sub.language_summaries.clone(),
10029 per_file_records: sub_files,
10030 skipped_file_records: vec![],
10031 warnings: vec![],
10032 submodule_summaries: vec![],
10033 git_commit_short: parent.git_commit_short.clone(),
10034 git_commit_long: parent.git_commit_long.clone(),
10035 git_branch: parent.git_branch.clone(),
10036 git_commit_author: parent.git_commit_author.clone(),
10037 git_commit_date: parent.git_commit_date.clone(),
10038 git_tags: parent.git_tags.clone(),
10039 git_nearest_tag: parent.git_nearest_tag.clone(),
10040 git_remote_url: parent.git_remote_url.clone(),
10041 }
10042}
10043
10044pub(crate) fn sanitize_project_label(raw: &str) -> String {
10045 let candidate = Path::new(raw)
10046 .file_name()
10047 .and_then(|name| name.to_str())
10048 .unwrap_or("project");
10049
10050 let mut value = String::with_capacity(candidate.len());
10051 for ch in candidate.chars() {
10052 if ch.is_ascii_alphanumeric() {
10053 value.push(ch.to_ascii_lowercase());
10054 } else {
10055 value.push('-');
10056 }
10057 }
10058
10059 let compact = value.trim_matches('-').to_string();
10060 if compact.is_empty() {
10061 "project".to_string()
10062 } else {
10063 compact
10064 }
10065}
10066
10067fn strip_unc_prefix(path: PathBuf) -> PathBuf {
10070 let s = path.to_string_lossy();
10071 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10072 return PathBuf::from(format!(r"\\{rest}"));
10073 }
10074 if let Some(rest) = s.strip_prefix(r"\\?\") {
10075 return PathBuf::from(rest);
10076 }
10077 path
10078}
10079
10080fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
10083 let base = if let Some(rest) = remote.strip_prefix("git@") {
10084 let (host, path) = rest.split_once(':')?;
10085 format!("https://{}/{}", host, path.trim_end_matches(".git"))
10086 } else if remote.starts_with("https://") || remote.starts_with("http://") {
10087 remote
10088 .trim_end_matches('/')
10089 .trim_end_matches(".git")
10090 .to_owned()
10091 } else {
10092 return None;
10093 };
10094 let base = base.trim_end_matches('/');
10095 if base.contains("gitlab.com") || base.contains("gitlab.") {
10097 Some(format!("{}/-/commit/{}", base, sha))
10098 } else if base.contains("bitbucket.org") {
10099 Some(format!("{}/commits/{}", base, sha))
10100 } else {
10101 Some(format!("{}/commit/{}", base, sha))
10102 }
10103}
10104
10105fn display_path(path: &Path) -> String {
10106 let s = path.to_string_lossy();
10107 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10112 return format!(r"\\{rest}");
10113 }
10114 if let Some(rest) = s.strip_prefix(r"\\?\") {
10115 return rest.to_owned();
10116 }
10117 s.into_owned()
10118}
10119
10120fn sanitize_path_str(s: &str) -> String {
10121 if let Some(rest) = s.strip_prefix("//?/UNC/") {
10125 return format!("//{rest}");
10126 }
10127 if let Some(rest) = s.strip_prefix("//?/") {
10128 return rest.to_owned();
10129 }
10130 display_path(Path::new(s))
10131}
10132
10133fn workspace_root() -> PathBuf {
10134 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
10136 let p = PathBuf::from(root);
10137 if p.is_dir() {
10138 return p;
10139 }
10140 }
10141
10142 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
10145}
10146
10147fn make_git_label(repo: &str, ref_name: &str) -> String {
10149 if repo.is_empty() || ref_name.is_empty() {
10150 return String::new();
10151 }
10152 let base = repo
10153 .trim_end_matches('/')
10154 .trim_end_matches(".git")
10155 .rsplit('/')
10156 .next()
10157 .unwrap_or("repo");
10158 let ref_safe: String = ref_name
10159 .chars()
10160 .map(|c| {
10161 if c.is_alphanumeric() || c == '-' || c == '.' {
10162 c
10163 } else {
10164 '_'
10165 }
10166 })
10167 .collect();
10168 format!("{base}_at_{ref_safe}_sloc")
10169}
10170
10171fn desktop_dir() -> PathBuf {
10173 if let Ok(profile) = std::env::var("USERPROFILE") {
10174 let p = PathBuf::from(profile).join("Desktop");
10175 if p.exists() {
10176 return p;
10177 }
10178 }
10179 if let Ok(home) = std::env::var("HOME") {
10180 let p = PathBuf::from(home).join("Desktop");
10181 if p.exists() {
10182 return p;
10183 }
10184 }
10185 workspace_root().join("out").join("web")
10186}
10187
10188fn resolve_input_path(raw: &str) -> PathBuf {
10189 let trimmed = raw.trim();
10190 if trimmed.is_empty() {
10191 return workspace_root().join("samples").join("basic");
10192 }
10193
10194 let candidate = PathBuf::from(trimmed);
10195 let resolved = if candidate.is_absolute() {
10196 candidate
10197 } else {
10198 let rooted = workspace_root().join(&candidate);
10199 if rooted.exists() {
10200 rooted
10201 } else {
10202 workspace_root().join(candidate)
10203 }
10204 };
10205
10206 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
10209 PathBuf::from(display_path(&canonical))
10210}
10211
10212fn dir_size_bytes(path: &Path) -> u64 {
10213 let mut total = 0u64;
10214 if let Ok(rd) = fs::read_dir(path) {
10215 for entry in rd.filter_map(Result::ok) {
10216 let p = entry.path();
10217 if p.is_file() {
10218 if let Ok(meta) = p.metadata() {
10219 total += meta.len();
10220 }
10221 } else if p.is_dir() {
10222 total += dir_size_bytes(&p);
10223 }
10224 }
10225 }
10226 total
10227}
10228
10229#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
10231 if bytes >= 1_073_741_824 {
10232 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
10233 } else if bytes >= 1_048_576 {
10234 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
10235 } else if bytes >= 1_024 {
10236 format!("{:.0} KB", bytes as f64 / 1_024.0)
10237 } else {
10238 format!("{bytes} B")
10239 }
10240}
10241
10242fn render_submodule_chips(
10243 root: &Path,
10244 submodules: &[(String, std::path::PathBuf)],
10245 out: &mut String,
10246) {
10247 use std::fmt::Write as _;
10248 let count = submodules.len();
10249 out.push_str(r#"<div class="submodule-preview-strip">"#);
10250 write!(
10251 out,
10252 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>"#,
10253 if count == 1 { "" } else { "s" }
10254 )
10255 .ok();
10256 out.push_str(r#"<div class="submodule-preview-chips">"#);
10257 for (sub_name, sub_rel_path) in submodules {
10258 let sub_abs = root.join(sub_rel_path);
10259 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
10260 let mut sub_stats = PreviewStats::default();
10261 let mut sub_rows: Vec<PreviewRow> = Vec::new();
10262 let mut sub_langs: Vec<&'static str> = Vec::new();
10263 let mut sub_budget = PreviewBudget {
10264 shown: 0,
10265 max_entries: 2000,
10266 max_depth: 9,
10267 };
10268 let mut sub_next_id = 1usize;
10269 let _ = collect_preview_rows(
10270 &sub_abs,
10271 &sub_abs,
10272 0,
10273 None,
10274 &mut sub_next_id,
10275 &mut sub_budget,
10276 &mut sub_stats,
10277 &mut sub_rows,
10278 &mut sub_langs,
10279 &[],
10280 &[],
10281 );
10282 let stats_json = format!(
10283 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
10284 sub_stats.directories,
10285 sub_stats.files,
10286 sub_stats.supported,
10287 sub_stats.skipped,
10288 sub_stats.unsupported
10289 );
10290 write!(
10291 out,
10292 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>"#,
10293 escape_html(sub_name),
10294 escape_html(&sub_rel_path.to_string_lossy()),
10295 escape_html(&sub_size),
10296 escape_html(&stats_json),
10297 escape_html(sub_name),
10298 escape_html(&sub_size),
10299 )
10300 .ok();
10301 }
10302 out.push_str(
10303 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
10304 );
10305 out.push_str(r"</div>");
10306}
10307
10308fn render_language_pills_row(languages: &[&str], out: &mut String) {
10309 use std::fmt::Write as _;
10310 if languages.is_empty() {
10311 out.push_str(
10312 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
10313 );
10314 return;
10315 }
10316 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10317 for language in languages {
10318 if let Some(icon) = language_icon_file(language) {
10319 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();
10320 } else if let Some(svg) = language_inline_svg(language) {
10321 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();
10322 } else {
10323 write!(
10324 out,
10325 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10326 escape_html(&language.to_ascii_lowercase()),
10327 escape_html(language)
10328 )
10329 .ok();
10330 }
10331 }
10332}
10333
10334#[allow(clippy::too_many_lines)]
10335fn build_preview_html(
10336 root: &Path,
10337 include_patterns: &[String],
10338 exclude_patterns: &[String],
10339) -> Result<String> {
10340 if !root.exists() {
10341 return Ok(format!(
10342 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10343 escape_html(&display_path(root))
10344 ));
10345 }
10346
10347 let _selected = display_path(root);
10348 let mut stats = PreviewStats::default();
10349 let mut rows = Vec::new();
10350 let mut languages = Vec::new();
10351 let mut budget = PreviewBudget {
10352 shown: 0,
10353 max_entries: 600,
10354 max_depth: 9,
10355 };
10356 let mut next_row_id = 1usize;
10357
10358 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10359 || root.to_string_lossy().into_owned(),
10360 std::string::ToString::to_string,
10361 );
10362 let root_modified = root
10363 .metadata()
10364 .ok()
10365 .and_then(|meta| meta.modified().ok())
10366 .map_or_else(|| "-".to_string(), format_system_time);
10367
10368 rows.push(PreviewRow {
10369 row_id: 0,
10370 parent_row_id: None,
10371 depth: 0,
10372 name: format!("{root_name}/"),
10373 kind: PreviewKind::Dir,
10374 is_dir: true,
10375 language: None,
10376 modified: root_modified,
10377 type_label: "Directory".to_string(),
10378 });
10379 collect_preview_rows(
10380 root,
10381 root,
10382 0,
10383 Some(0),
10384 &mut next_row_id,
10385 &mut budget,
10386 &mut stats,
10387 &mut rows,
10388 &mut languages,
10389 include_patterns,
10390 exclude_patterns,
10391 )?;
10392
10393 let root_size = format_dir_size(dir_size_bytes(root));
10394
10395 let mut out = String::new();
10396 write!(
10397 out,
10398 r#"<div class="explorer-wrap" data-project-size="{}">"#,
10399 escape_html(&root_size)
10400 )
10401 .ok();
10402 out.push_str(r#"<div class="explorer-toolbar compact">"#);
10403 out.push_str(r#"<div class="explorer-title-group">"#);
10404 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10405 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10406 out.push_str(r"</div></div>");
10407
10408 out.push_str(r#"<div class="scope-stats">"#);
10409 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();
10410 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();
10411 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();
10412 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();
10413 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();
10414 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>"#);
10415 out.push_str(r"</div>");
10416
10417 let submodules = sloc_core::detect_submodules(root);
10418 if !submodules.is_empty() {
10419 render_submodule_chips(root, &submodules, &mut out);
10420 }
10421
10422 out.push_str(r#"<div class="scope-info-row">"#);
10423 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10424 render_language_pills_row(&languages, &mut out);
10425 out.push_str(r"</div></div>");
10426 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>"#);
10427 out.push_str(r"</div>");
10428
10429 out.push_str(r#"<div class="file-explorer-shell">"#);
10430 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>"#);
10431 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>"#);
10432 out.push_str(r#"<div class="file-explorer-tree">"#);
10433 for row in rows {
10434 let status_label = row.kind.label();
10435 let lang_attr = row.language.unwrap_or("");
10436 let toggle_html = if row.is_dir {
10437 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10438 .to_string()
10439 } else {
10440 r#"<span class="tree-bullet">•</span>"#.to_string()
10441 };
10442 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();
10443 }
10444 if budget.shown >= budget.max_entries {
10445 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>"#);
10446 }
10447 out.push_str(r"</div></div></div>");
10448
10449 Ok(out)
10450}
10451
10452#[derive(Default)]
10453struct PreviewStats {
10454 directories: usize,
10455 files: usize,
10456 supported: usize,
10457 skipped: usize,
10458 unsupported: usize,
10459}
10460
10461struct PreviewRow {
10462 row_id: usize,
10463 parent_row_id: Option<usize>,
10464 depth: usize,
10465 name: String,
10466 kind: PreviewKind,
10467 is_dir: bool,
10468 language: Option<&'static str>,
10469 modified: String,
10470 type_label: String,
10471}
10472
10473#[derive(Copy, Clone)]
10474enum PreviewKind {
10475 Dir,
10476 Supported,
10477 Skipped,
10478 Unsupported,
10479}
10480
10481impl PreviewKind {
10482 const fn filter_key(self) -> &'static str {
10483 match self {
10484 Self::Dir => "dir",
10485 Self::Supported => "supported",
10486 Self::Skipped => "skipped",
10487 Self::Unsupported => "unsupported",
10488 }
10489 }
10490
10491 const fn label(self) -> &'static str {
10492 match self {
10493 Self::Dir => "dir",
10494 Self::Supported => "supported",
10495 Self::Skipped => "skipped by policy",
10496 Self::Unsupported => "unsupported",
10497 }
10498 }
10499
10500 const fn badge_class(self) -> &'static str {
10501 match self {
10502 Self::Dir => "badge badge-dir",
10503 Self::Supported => "badge badge-scan",
10504 Self::Skipped => "badge badge-skip",
10505 Self::Unsupported => "badge badge-unsupported",
10506 }
10507 }
10508
10509 const fn node_class(self) -> &'static str {
10510 match self {
10511 Self::Dir => "tree-node-dir",
10512 Self::Supported => "tree-node-supported",
10513 Self::Skipped => "tree-node-skipped",
10514 Self::Unsupported => "tree-node-unsupported",
10515 }
10516 }
10517}
10518
10519struct PreviewBudget {
10520 shown: usize,
10521 max_entries: usize,
10522 max_depth: usize,
10523}
10524
10525#[allow(clippy::too_many_arguments)]
10528fn handle_preview_dir_entry(
10529 root: &Path,
10530 path: &Path,
10531 name: &str,
10532 modified: String,
10533 depth: usize,
10534 parent_row_id: Option<usize>,
10535 row_id: usize,
10536 next_row_id: &mut usize,
10537 budget: &mut PreviewBudget,
10538 stats: &mut PreviewStats,
10539 rows: &mut Vec<PreviewRow>,
10540 languages: &mut Vec<&'static str>,
10541 include_patterns: &[String],
10542 exclude_patterns: &[String],
10543) -> Result<()> {
10544 let relative = preview_relative_path(root, path);
10545 if should_skip_preview_directory(&relative, exclude_patterns) {
10546 return Ok(());
10547 }
10548 stats.directories += 1;
10549 rows.push(PreviewRow {
10550 row_id,
10551 parent_row_id,
10552 depth: depth + 1,
10553 name: format!("{name}/"),
10554 kind: PreviewKind::Dir,
10555 is_dir: true,
10556 language: None,
10557 modified,
10558 type_label: "Directory".to_string(),
10559 });
10560 budget.shown += 1;
10561 if !matches!(name, ".git" | "node_modules" | "target") {
10562 collect_preview_rows(
10563 root,
10564 path,
10565 depth + 1,
10566 Some(row_id),
10567 next_row_id,
10568 budget,
10569 stats,
10570 rows,
10571 languages,
10572 include_patterns,
10573 exclude_patterns,
10574 )?;
10575 }
10576 Ok(())
10577}
10578
10579#[allow(clippy::too_many_arguments)]
10581fn handle_preview_file_entry(
10582 root: &Path,
10583 path: &Path,
10584 name: &str,
10585 modified: String,
10586 depth: usize,
10587 parent_row_id: Option<usize>,
10588 row_id: usize,
10589 budget: &mut PreviewBudget,
10590 stats: &mut PreviewStats,
10591 rows: &mut Vec<PreviewRow>,
10592 languages: &mut Vec<&'static str>,
10593 include_patterns: &[String],
10594 exclude_patterns: &[String],
10595) {
10596 let relative = preview_relative_path(root, path);
10597 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10598 return;
10599 }
10600 stats.files += 1;
10601 let kind = classify_preview_file(name);
10602 match kind {
10603 PreviewKind::Supported => stats.supported += 1,
10604 PreviewKind::Skipped => stats.skipped += 1,
10605 PreviewKind::Unsupported => stats.unsupported += 1,
10606 PreviewKind::Dir => {}
10607 }
10608 let language = detect_language_name(name);
10609 if let Some(lang) = language {
10610 if !languages.contains(&lang) {
10611 languages.push(lang);
10612 }
10613 }
10614 rows.push(PreviewRow {
10615 row_id,
10616 parent_row_id,
10617 depth: depth + 1,
10618 name: name.to_owned(),
10619 kind,
10620 is_dir: false,
10621 language,
10622 modified,
10623 type_label: preview_type_label(name, language, kind),
10624 });
10625 budget.shown += 1;
10626}
10627
10628#[allow(clippy::too_many_arguments)]
10629#[allow(clippy::too_many_lines)]
10630fn collect_preview_rows(
10631 root: &Path,
10632 dir: &Path,
10633 depth: usize,
10634 parent_row_id: Option<usize>,
10635 next_row_id: &mut usize,
10636 budget: &mut PreviewBudget,
10637 stats: &mut PreviewStats,
10638 rows: &mut Vec<PreviewRow>,
10639 languages: &mut Vec<&'static str>,
10640 include_patterns: &[String],
10641 exclude_patterns: &[String],
10642) -> Result<()> {
10643 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10644 return Ok(());
10645 }
10646
10647 let mut entries = fs::read_dir(dir)
10648 .with_context(|| format!("failed to read directory {}", dir.display()))?
10649 .filter_map(std::result::Result::ok)
10650 .collect::<Vec<_>>();
10651 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10652
10653 for entry in entries {
10654 if budget.shown >= budget.max_entries {
10655 break;
10656 }
10657
10658 let path = entry.path();
10659 let name = entry.file_name().to_string_lossy().into_owned();
10660 let Ok(metadata) = entry.metadata() else {
10661 continue;
10662 };
10663 let row_id = *next_row_id;
10664 *next_row_id += 1;
10665 let modified = metadata
10666 .modified()
10667 .ok()
10668 .map_or_else(|| "-".to_string(), format_system_time);
10669
10670 if metadata.is_dir() {
10671 handle_preview_dir_entry(
10672 root,
10673 &path,
10674 &name,
10675 modified,
10676 depth,
10677 parent_row_id,
10678 row_id,
10679 next_row_id,
10680 budget,
10681 stats,
10682 rows,
10683 languages,
10684 include_patterns,
10685 exclude_patterns,
10686 )?;
10687 continue;
10688 }
10689
10690 if metadata.is_file() {
10691 handle_preview_file_entry(
10692 root,
10693 &path,
10694 &name,
10695 modified,
10696 depth,
10697 parent_row_id,
10698 row_id,
10699 budget,
10700 stats,
10701 rows,
10702 languages,
10703 include_patterns,
10704 exclude_patterns,
10705 );
10706 }
10707 }
10708
10709 Ok(())
10710}
10711
10712fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10713 if let Some(language) = language {
10714 return format!("{language} source");
10715 }
10716 let lower = name.to_ascii_lowercase();
10717 let ext = Path::new(&lower)
10718 .extension()
10719 .and_then(|e| e.to_str())
10720 .unwrap_or("");
10721 match kind {
10722 PreviewKind::Skipped => {
10723 if lower.ends_with(".min.js") {
10724 "Minified asset".to_string()
10725 } else if [
10726 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10727 ]
10728 .contains(&ext)
10729 {
10730 "Binary or archive".to_string()
10731 } else {
10732 "Skipped file".to_string()
10733 }
10734 }
10735 PreviewKind::Unsupported => {
10736 if ext.is_empty() {
10737 "Unsupported file".to_string()
10738 } else {
10739 format!("{} file", ext.to_ascii_uppercase())
10740 }
10741 }
10742 PreviewKind::Supported => "Supported source".to_string(),
10743 PreviewKind::Dir => "Directory".to_string(),
10744 }
10745}
10746
10747fn format_system_time(time: SystemTime) -> String {
10748 #[allow(clippy::cast_possible_wrap)]
10749 let secs = match time.duration_since(UNIX_EPOCH) {
10750 Ok(duration) => duration.as_secs() as i64,
10751 Err(_) => return "-".to_string(),
10752 };
10753 let days = secs.div_euclid(86_400);
10754 let secs_of_day = secs.rem_euclid(86_400);
10755 let (year, month, day) = civil_from_days(days);
10756 let hour = secs_of_day / 3_600;
10757 let minute = (secs_of_day % 3_600) / 60;
10758 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10759}
10760
10761#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10762fn civil_from_days(days: i64) -> (i32, u32, u32) {
10763 let z = days + 719_468;
10764 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10765 let doe = z - era * 146_097;
10766 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10767 let y = yoe + era * 400;
10768 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10769 let mp = (5 * doy + 2) / 153;
10770 let d = doy - (153 * mp + 2) / 5 + 1;
10771 let m = mp + if mp < 10 { 3 } else { -9 };
10772 let year = y + i64::from(m <= 2);
10773 (year as i32, m as u32, d as u32)
10774}
10775
10776#[allow(clippy::case_sensitive_file_extension_comparisons)]
10779fn detect_language_name(name: &str) -> Option<&'static str> {
10780 let lower = name.to_ascii_lowercase();
10781 if lower.ends_with(".c") || lower.ends_with(".h") {
10782 Some("C")
10783 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10784 .iter()
10785 .any(|s| lower.ends_with(s))
10786 {
10787 Some("C++")
10788 } else if lower.ends_with(".cs") {
10789 Some("C#")
10790 } else if lower.ends_with(".py") {
10791 Some("Python")
10792 } else if lower.ends_with(".sh") {
10793 Some("Shell")
10794 } else if [".ps1", ".psm1", ".psd1"]
10795 .iter()
10796 .any(|s| lower.ends_with(s))
10797 {
10798 Some("PowerShell")
10799 } else {
10800 None
10801 }
10802}
10803
10804fn language_icon_file(language: &str) -> Option<&'static str> {
10805 match language {
10806 "C" => Some("c.png"),
10807 "C++" => Some("cpp.png"),
10808 "C#" => Some("c-sharp.png"),
10809 "Python" => Some("python.png"),
10810 "Shell" => Some("shell.png"),
10811 "PowerShell" => Some("powershell.png"),
10812 "JavaScript" => Some("java-script.png"),
10813 "HTML" => Some("html-5.png"),
10814 "Java" => Some("java.png"),
10815 "Visual Basic" => Some("visual-basic.png"),
10816 "Assembly" => Some("asm.png"),
10817 "Go" => Some("go.png"),
10818 "R" => Some("r.png"),
10819 "XML" => Some("xml.png"),
10820 "Groovy" => Some("groovy.png"),
10821 "Dockerfile" => Some("docker.png"),
10822 "Makefile" => Some("makefile.svg"),
10823 "Perl" => Some("perl.svg"),
10824 _ => None,
10825 }
10826}
10827
10828fn language_inline_svg(language: &str) -> Option<&'static str> {
10833 match language {
10834 "Rust" => Some(
10835 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>"##,
10836 ),
10837 "TypeScript" => Some(
10838 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>"##,
10839 ),
10840 _ => None,
10841 }
10842}
10843
10844#[allow(clippy::case_sensitive_file_extension_comparisons)]
10847fn classify_preview_file(name: &str) -> PreviewKind {
10848 let lower = name.to_ascii_lowercase();
10849
10850 let scannable = [
10851 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10852 ".psm1", ".psd1",
10853 ]
10854 .iter()
10855 .any(|suffix| lower.ends_with(suffix));
10856
10857 if scannable {
10858 PreviewKind::Supported
10859 } else if lower.ends_with(".min.js")
10860 || lower.ends_with(".lock")
10861 || lower.ends_with(".png")
10862 || lower.ends_with(".jpg")
10863 || lower.ends_with(".jpeg")
10864 || lower.ends_with(".gif")
10865 || lower.ends_with(".zip")
10866 || lower.ends_with(".pdf")
10867 || lower.ends_with(".pyc")
10868 || lower.ends_with(".xz")
10869 || lower.ends_with(".tar")
10870 || lower.ends_with(".gz")
10871 {
10872 PreviewKind::Skipped
10873 } else {
10874 PreviewKind::Unsupported
10875 }
10876}
10877
10878fn preview_relative_path(root: &Path, path: &Path) -> String {
10879 path.strip_prefix(root)
10880 .ok()
10881 .unwrap_or(path)
10882 .to_string_lossy()
10883 .replace('\\', "/")
10884 .trim_matches('/')
10885 .to_string()
10886}
10887
10888fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10889 if relative.is_empty() {
10890 return false;
10891 }
10892
10893 exclude_patterns.iter().any(|pattern| {
10894 wildcard_match(pattern, relative)
10895 || wildcard_match(pattern, &format!("{relative}/"))
10896 || wildcard_match(pattern, &format!("{relative}/placeholder"))
10897 })
10898}
10899
10900fn should_include_preview_file(
10901 relative: &str,
10902 include_patterns: &[String],
10903 exclude_patterns: &[String],
10904) -> bool {
10905 if relative.is_empty() {
10906 return true;
10907 }
10908
10909 let included = include_patterns.is_empty()
10910 || include_patterns
10911 .iter()
10912 .any(|pattern| wildcard_match(pattern, relative));
10913 let excluded = exclude_patterns
10914 .iter()
10915 .any(|pattern| wildcard_match(pattern, relative));
10916
10917 included && !excluded
10918}
10919
10920fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10921 let pattern = pattern.trim().replace('\\', "/");
10922 let candidate = candidate.trim().replace('\\', "/");
10923 let p = pattern.as_bytes();
10924 let c = candidate.as_bytes();
10925 let mut pi = 0usize;
10926 let mut ci = 0usize;
10927 let mut star: Option<usize> = None;
10928 let mut star_match = 0usize;
10929
10930 while ci < c.len() {
10931 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10932 pi += 1;
10933 ci += 1;
10934 } else if pi < p.len() && p[pi] == b'*' {
10935 while pi < p.len() && p[pi] == b'*' {
10936 pi += 1;
10937 }
10938 star = Some(pi);
10939 star_match = ci;
10940 } else if let Some(star_pi) = star {
10941 star_match += 1;
10942 ci = star_match;
10943 pi = star_pi;
10944 } else {
10945 return false;
10946 }
10947 }
10948
10949 while pi < p.len() && p[pi] == b'*' {
10950 pi += 1;
10951 }
10952
10953 pi == p.len()
10954}
10955
10956fn escape_html(value: &str) -> String {
10957 value
10958 .replace('&', "&")
10959 .replace('<', "<")
10960 .replace('>', ">")
10961 .replace('"', """)
10962 .replace('\'', "'")
10963}
10964
10965#[derive(Clone)]
10966struct SubmoduleRow {
10967 name: String,
10968 relative_path: String,
10969 files_analyzed: u64,
10970 code_lines: u64,
10971 comment_lines: u64,
10972 blank_lines: u64,
10973 total_physical_lines: u64,
10974 html_url: Option<String>,
10975}
10976
10977#[derive(Template)]
10978#[template(
10979 source = r##"
10980<!doctype html>
10981<html lang="en">
10982<head>
10983 <meta charset="utf-8">
10984 <title>OxideSLOC | tmp-sloc</title>
10985 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10986 <style nonce="{{ csp_nonce }}">
10987 :root {
10988 --bg: #efe9e2;
10989 --surface: #fcfaf7;
10990 --surface-2: #f7f0e8;
10991 --surface-3: #efe3d5;
10992 --line: #dfcfbf;
10993 --line-strong: #cfb29c;
10994 --text: #2f241c;
10995 --muted: #6f6257;
10996 --muted-2: #917f71;
10997 --nav: #b85d33;
10998 --nav-2: #7a371b;
10999 --accent: #2563eb;
11000 --accent-2: #1d4ed8;
11001 --oxide: #b85d33;
11002 --oxide-2: #8f4220;
11003 --success-bg: #eaf9ee;
11004 --success-text: #1c8746;
11005 --warn-bg: #fff2d8;
11006 --warn-text: #926000;
11007 --danger-bg: #fdeaea;
11008 --danger-text: #b33b3b;
11009 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
11010 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
11011 --radius: 14px;
11012 }
11013
11014 body.dark-theme {
11015 --bg: #1b1511;
11016 --surface: #261c17;
11017 --surface-2: #2d221d;
11018 --surface-3: #372922;
11019 --line: #524238;
11020 --line-strong: #6c5649;
11021 --text: #f5ece6;
11022 --muted: #c7b7aa;
11023 --muted-2: #aa9485;
11024 --nav: #b85d33;
11025 --nav-2: #7a371b;
11026 --accent: #6f9bff;
11027 --accent-2: #4a78ee;
11028 --oxide: #d37a4c;
11029 --oxide-2: #b35428;
11030 --success-bg: #163927;
11031 --success-text: #8fe2a8;
11032 --warn-bg: #3c2d11;
11033 --warn-text: #f3cb75;
11034 --danger-bg: #3d1f1f;
11035 --danger-text: #ff9f9f;
11036 --shadow: 0 14px 28px rgba(0,0,0,0.28);
11037 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
11038 }
11039
11040 * { box-sizing: border-box; }
11041 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); }
11042 html { overflow-y: scroll; }
11043 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
11044 .top-nav, .page, .loading { position: relative; z-index: 2; }
11045 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
11046 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
11047 .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); }
11048 .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; }
11049 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
11050 .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)); }
11051 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
11052 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
11053 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
11054 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
11055 .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; }
11056 .nav-project-pill.visible { display:inline-flex; }
11057 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
11058 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
11059 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
11060 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
11061 @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; } }
11062 .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; }
11063 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
11064 .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; }
11065 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
11066 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
11067 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
11068 .theme-toggle .icon-sun { display:none; }
11069 body.dark-theme .theme-toggle .icon-sun { display:block; }
11070 body.dark-theme .theme-toggle .icon-moon { display:none; }
11071 .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;}
11072 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
11073 .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);}
11074 .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;}
11075 .settings-close:hover{color:var(--text);background:var(--surface-2);}
11076 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
11077 .settings-modal-body{padding:14px 16px 16px;}
11078 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
11079 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
11080 .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;}
11081 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
11082 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
11083 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
11084 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
11085 .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;}
11086 .tz-select:focus{border-color:var(--oxide);}
11087 .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; }
11088 .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;}
11089 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
11090 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
11091 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
11092 .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; }
11093 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
11094 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
11095 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
11096 .wb-stats-header { padding: 10px 24px 0; }
11097 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
11098 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
11099 .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; }
11100 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11101 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11102 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11103 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
11104 .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; }
11105 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
11106 .ws-stat-analyzers { position: relative; }
11107 .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; }
11108 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
11109 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
11110 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
11111 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
11112 .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; }
11113 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
11114 .ws-divider { display: none; }
11115 .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%; }
11116 .ws-path-link:hover { color:var(--oxide); }
11117 body.dark-theme .ws-path-link { color:var(--oxide); }
11118 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
11119 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11120 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
11121 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11122 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
11123 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
11124 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
11125 .ws-mini-box-lg { flex:2 1 0; }
11126 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
11127 .ws-mini-box-br { flex:1.5 1 0; }
11128 .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); }
11129 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
11130 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
11131 #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; }
11132 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
11133 .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; }
11134 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
11135 .git-source-banner strong { font-weight:800; color:var(--text); }
11136 .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; }
11137 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
11138 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
11139 .git-source-banner a:hover { text-decoration:underline; }
11140 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
11141 .path-scope-sep { background:var(--line); margin:4px 14px; }
11142 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
11143 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
11144 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
11145 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
11146 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
11147 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
11148 .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; }
11149 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11150 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11151 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11152 .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; }
11153 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
11154 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
11155 [data-wb-tip] { cursor:help; }
11156 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
11157 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
11158 .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; }
11159 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
11160 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
11161 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
11162 .summary-card, .card, .step-nav, .explainer-card, .review-card, .workspace-card, .artifact-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; }
11163 .summary-card:hover, .workspace-card:hover, .explainer-card:hover, .artifact-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
11164 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
11165 .side-info-card { padding: 18px; }
11166 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
11167 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
11168 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
11169 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
11170 .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); }
11171 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
11172 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11173 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
11174 .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; }
11175 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
11176 .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; }
11177 .side-stack::-webkit-scrollbar { display: none; }
11178 .step-nav { padding: 20px 16px; }
11179 .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); }
11180 .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; }
11181 .step-button:hover { background: var(--surface-2); }
11182 .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); }
11183 .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; }
11184 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11185 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
11186 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
11187 .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); }
11188 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
11189 .step-nav-sum-row:last-child { border-bottom:none; }
11190 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
11191 .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; }
11192 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
11193 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
11194 .quick-scan-section { padding: 10px 4px 14px; }
11195 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
11196 .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; }
11197 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
11198 .quick-scan-btn:active { transform:translateY(0); }
11199 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
11200 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
11201 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
11202 @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);} }
11203 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
11204 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
11205 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
11206 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
11207 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
11208 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
11209 .step-button.done .step-check { opacity:1; }
11210 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
11211 .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; }
11212 .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; }
11213 .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; }
11214 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
11215 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
11216 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
11217 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
11218 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
11219 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
11220 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
11221 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
11222 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
11223 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
11224 .card-body { padding: 22px; }
11225 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
11226 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
11227 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
11228 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
11229 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
11230 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
11231 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
11232 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
11233 .field { min-width:0; }
11234 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
11235 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; }
11236 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); }
11237 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
11238 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); }
11239 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
11240 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11241 .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; }
11242 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
11243 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
11244 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
11245 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
11246 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
11247 .input-group.compact { grid-template-columns: 1fr auto auto; }
11248 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
11249 .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)); }
11250 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
11251 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
11252 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
11253 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
11254 .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; }
11255 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
11256 .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; }
11257 .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); }
11258 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
11259 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
11260 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
11261 button.secondary { background: var(--surface); }
11262 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11263 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
11264 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
11265 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11266 .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); }
11267 .section + .wizard-actions { border-top: none; padding-top: 0; }
11268 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
11269 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11270 .field-help-grid.coupled-help { margin-top: 12px; }
11271 .field-help-grid.preset-grid { align-items: start; }
11272 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
11273 .preset-inline-row .field { margin: 0; }
11274 .preset-inline-row .explainer-card { margin: 0; }
11275 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
11276 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
11277 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
11278 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
11279 .preset-kv-row > :last-child { flex:1; min-width:0; }
11280 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
11281 .output-field-row .field { margin: 0; }
11282 .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; }
11283 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
11284 .step3-subtitle { margin-bottom: 10px; max-width: none; }
11285 .counting-intro { margin-bottom: 8px; max-width: none; }
11286 .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; }
11287 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
11288 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
11289 .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; }
11290 .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; }
11291 .section-spacer-top { margin-top: 28px; }
11292 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
11293 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
11294 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
11295 .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); }
11296 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
11297 .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; }
11298 .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; }
11299 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
11300 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11301 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
11302 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
11303 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
11304 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
11305 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
11306 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
11307 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
11308 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
11309 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
11310 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
11311 .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); }
11312 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
11313 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
11314 .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; }
11315 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
11316 .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; }
11317 .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; }
11318 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11319 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11320 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11321 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11322 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11323 .advanced-rule-description strong { color: var(--text); }
11324 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11325 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11326 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11327 .review-link:hover { text-decoration: underline; }
11328 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11329 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11330 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11331 .artifact-card .marker { position:absolute; top: 12px; right: 12px; width: 22px; height: 22px; border-radius: 999px; border:2px solid var(--line-strong); display:flex; align-items:center; justify-content:center; font-size: 12px; color: transparent; }
11332 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11333 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11334 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11335 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11336 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11337 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11338 .artifact-icon { width: 42px; height: 42px; border-radius: 12px; background: var(--surface-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size: 22px; font-weight: 900; }
11339 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11340 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11341 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11342 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11343 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11344 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11345 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11346 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11347 .review-card ul { padding-left: 18px; margin: 0; }
11348 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11349 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11350 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11351 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11352 .review-card { min-height: 0; }
11353 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11354 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11355 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11356 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11357 .lang-overflow-chip { position:relative; cursor:default; }
11358 .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; }
11359 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11360 .git-inline-row { align-items:start; }
11361 .mixed-line-card { display:flex; flex-direction:column; }
11362 .preset-inline-row .toggle-card { justify-content: center; }
11363 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11364 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11365 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11366 .explorer-title { font-size: 18px; font-weight: 850; }
11367 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11368 .explorer-subtitle.wide { max-width: none; }
11369 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11370 .better-spacing { align-items:flex-start; justify-content:flex-end; }
11371 .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; }
11372 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11373 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11374 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11375 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11376 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11377 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11378 .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; }
11379 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11380 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11381 .scope-stat-button.supported { background: var(--success-bg); }
11382 .scope-stat-button.skipped { background: var(--warn-bg); }
11383 .scope-stat-button.unsupported { background: var(--danger-bg); }
11384 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11385 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11386 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11387 [data-tooltip] { position: relative; }
11388 [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); }
11389 [data-tooltip]:hover::after { display: block; }
11390 .scope-stat-button[data-tooltip] { cursor: pointer; }
11391 .badge[data-tooltip] { cursor: help; }
11392 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11393 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11394 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11395 .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; }
11396 .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; }
11397 code { display:inline-block; margin-top:0; padding:2px 7px; }
11398 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11399 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11400 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11401 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11402 .language-pill.muted-pill { color: var(--muted); }
11403 button.language-pill { appearance:none; cursor:pointer; }
11404 .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); }
11405 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11406 .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; }
11407 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11408 .file-explorer-search-row { margin-left: auto; }
11409 .explorer-filter-select { min-width: 170px; width: 170px; }
11410 .explorer-search { min-width: 300px; width: 300px; }
11411 .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); }
11412 .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; }
11413 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11414 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11415 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11416 .file-explorer-tree { max-height: 640px; overflow:auto; }
11417 .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); }
11418 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11419 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11420 .tree-row.hidden-by-filter { display:none !important; }
11421 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11422 .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; }
11423 .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; }
11424 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11425 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11426 .tree-node { display:inline-flex; align-items:center; min-width:0; }
11427 .tree-node-dir { color: var(--text); font-weight: 800; }
11428 .tree-node-supported { color: var(--success-text); }
11429 .tree-node-skipped { color: var(--warn-text); }
11430 .tree-node-unsupported { color: var(--danger-text); }
11431 .tree-node-more { color: var(--muted-2); font-style: italic; }
11432 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11433 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11434 .tree-status-cell { display:flex; justify-content:flex-start; }
11435 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11436 .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; }
11437 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11438 .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; }
11439 @keyframes prevSpin { to { transform:rotate(360deg); } }
11440 .preview-loading-text { flex:1; min-width:0; }
11441 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
11442 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
11443 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11444 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11445 .cov-scan-idle { display:none; }
11446 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11447 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11448 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11449 .cov-scan-title { font-weight:600; font-size:12.5px; }
11450 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11451 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11452 .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; }
11453 .cov-scan-use:hover { opacity:.75; }
11454 .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; }
11455 .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; }
11456 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11457 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11458 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11459 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11460 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11461 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11462 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11463 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11464 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11465 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11466 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11467 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11468 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11469 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11470 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11471 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11472 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11473 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11474 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11475 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11476 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11477 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11478 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11479 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11480 .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); }
11481 .loading.active { display:flex; }
11482 .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; }
11483 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11484 .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; }
11485 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11486 .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; }
11487 .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; }
11488 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11489 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11490 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11491 .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; }
11492 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11493 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11494 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11495 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11496 .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; }
11497 .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; }
11498 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11499 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11500 .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; }
11501 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11502 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11503 .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; }
11504 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11505 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11506 .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; }
11507 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11508 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11509 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11510 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11511 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11512 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11513 .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; }
11514 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11515 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11516 .hidden { display:none !important; }
11517 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11518 .site-footer a{color:var(--muted);}
11519 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11520 @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; } }
11521 .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;}
11522 @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));}}
11523 .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;}
11524 .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; }
11525 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11526 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11527 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11528 .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; }
11529 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11530 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11531 .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; }
11532 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11533 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11534 .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; }
11535 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11536 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11537 .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; }
11538 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11539 .info-icon-btn:hover { color:var(--text); }
11540 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); }
11541 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11542 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11543 .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;}
11544 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11545 .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;}
11546 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11547 </style>
11548</head>
11549<body>
11550 <div class="background-watermarks" aria-hidden="true">
11551 <img src="/images/logo/logo-text.png" alt="" />
11552 <img src="/images/logo/logo-text.png" alt="" />
11553 <img src="/images/logo/logo-text.png" alt="" />
11554 <img src="/images/logo/logo-text.png" alt="" />
11555 <img src="/images/logo/logo-text.png" alt="" />
11556 <img src="/images/logo/logo-text.png" alt="" />
11557 <img src="/images/logo/logo-text.png" alt="" />
11558 <img src="/images/logo/logo-text.png" alt="" />
11559 <img src="/images/logo/logo-text.png" alt="" />
11560 <img src="/images/logo/logo-text.png" alt="" />
11561 <img src="/images/logo/logo-text.png" alt="" />
11562 <img src="/images/logo/logo-text.png" alt="" />
11563 <img src="/images/logo/logo-text.png" alt="" />
11564 <img src="/images/logo/logo-text.png" alt="" />
11565 </div>
11566 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11567 <div class="top-nav">
11568 <div class="top-nav-inner">
11569 <a class="brand" href="/">
11570 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11571 <div class="brand-copy">
11572 <div class="brand-title">OxideSLOC</div>
11573 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11574 </div>
11575 </a>
11576 <div class="nav-project-slot">
11577 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11578 <span class="nav-project-label">Project</span>
11579 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11580 </div>
11581 </div>
11582 <div class="nav-status">
11583 <a class="nav-pill" href="/">Home</a>
11584 <div class="nav-dropdown">
11585 <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>
11586 <div class="nav-dropdown-menu">
11587 <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>
11588 </div>
11589 </div>
11590 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11591 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11592 <div class="nav-dropdown">
11593 <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>
11594 <div class="nav-dropdown-menu">
11595 <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>
11596 </div>
11597 </div>
11598 <div class="server-status-wrap" id="server-status-wrap">
11599 <div class="nav-pill server-online-pill" id="server-status-pill">
11600 <span class="status-dot" id="status-dot"></span>
11601 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11602 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11603 </div>
11604 <div class="server-status-tip">
11605 {% if server_mode %}
11606 OxideSLOC is running in server mode — accessible on your LAN.
11607 {% else %}
11608 OxideSLOC is running locally — only accessible from this machine.
11609 {% endif %}
11610 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11611 </div>
11612 </div>
11613 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11614 <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>
11615 </button>
11616 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11617 <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>
11618 <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>
11619 </button>
11620 </div>
11621 </div>
11622 </div>
11623
11624 <div class="loading" id="loading">
11625 <div class="loading-card">
11626 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11627 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11628 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
11629 <div class="lc-path" id="lc-path"></div>
11630 <div class="lc-metrics" id="lc-metrics">
11631 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11632 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11633 <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>
11634 </div>
11635 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11636 <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>
11637 <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>
11638 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11639 <div class="lc-actions hidden" id="lc-actions">
11640 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11641 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11642 </div>
11643 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11644 <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>
11645 Cancel scan
11646 </button>
11647 </div>
11648 </div>
11649
11650 <div class="page">
11651 <div class="workbench-strip">
11652 <div class="workbench-box wb-stats">
11653 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11654 <span class="wb-stats-title">Analysis session</span>
11655 </div>
11656 <div class="ws-left">
11657 <div class="ws-stat ws-stat-analyzers">
11658 <span class="ws-label">Analyzers</span>
11659 <span class="ws-value">
11660 <span class="ws-badge">41 languages</span>
11661 </span>
11662 <div class="ws-lang-tooltip">
11663 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11664 <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>
11665 <div class="ws-lang-grid">
11666 <span class="ws-lang-item">Assembly</span>
11667 <span class="ws-lang-item">C</span>
11668 <span class="ws-lang-item">C++</span>
11669 <span class="ws-lang-item">C#</span>
11670 <span class="ws-lang-item">Clojure</span>
11671 <span class="ws-lang-item">CSS</span>
11672 <span class="ws-lang-item">Dart</span>
11673 <span class="ws-lang-item">Dockerfile</span>
11674 <span class="ws-lang-item">Elixir</span>
11675 <span class="ws-lang-item">Erlang</span>
11676 <span class="ws-lang-item">F#</span>
11677 <span class="ws-lang-item">Go</span>
11678 <span class="ws-lang-item">Groovy</span>
11679 <span class="ws-lang-item">Haskell</span>
11680 <span class="ws-lang-item">HTML</span>
11681 <span class="ws-lang-item">Java</span>
11682 <span class="ws-lang-item">JavaScript</span>
11683 <span class="ws-lang-item">Julia</span>
11684 <span class="ws-lang-item">Kotlin</span>
11685 <span class="ws-lang-item">Lua</span>
11686 <span class="ws-lang-item">Makefile</span>
11687 <span class="ws-lang-item">Nim</span>
11688 <span class="ws-lang-item">Obj-C</span>
11689 <span class="ws-lang-item">OCaml</span>
11690 <span class="ws-lang-item">Perl</span>
11691 <span class="ws-lang-item">PHP</span>
11692 <span class="ws-lang-item">PowerShell</span>
11693 <span class="ws-lang-item">Python</span>
11694 <span class="ws-lang-item">R</span>
11695 <span class="ws-lang-item">Ruby</span>
11696 <span class="ws-lang-item">Rust</span>
11697 <span class="ws-lang-item">Scala</span>
11698 <span class="ws-lang-item">SCSS</span>
11699 <span class="ws-lang-item">Shell</span>
11700 <span class="ws-lang-item">SQL</span>
11701 <span class="ws-lang-item">Svelte</span>
11702 <span class="ws-lang-item">Swift</span>
11703 <span class="ws-lang-item">TypeScript</span>
11704 <span class="ws-lang-item">Vue</span>
11705 <span class="ws-lang-item">XML</span>
11706 <span class="ws-lang-item">Zig</span>
11707 </div>
11708 </div>
11709 </div>
11710 <div class="ws-divider"></div>
11711 <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>
11712 <div class="ws-divider"></div>
11713 <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.">
11714 <span class="ws-label">Output</span>
11715 <span class="ws-value">
11716 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11717 <span id="ws-output-root">project/sloc</span>
11718 </button>
11719 </span>
11720 </div>
11721 </div>
11722 </div>
11723 <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.">
11724 <div class="ws-history-label">Scan history</div>
11725 <div class="ws-history-inner">
11726 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11727 <div class="ws-mini-label">Scans</div>
11728 <div class="ws-mini-value" id="ws-scan-count">—</div>
11729 </div>
11730 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11731 <div class="ws-mini-label">Last Scan</div>
11732 <div class="ws-mini-value" id="ws-last-scan">—</div>
11733 </div>
11734 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11735 <div class="ws-mini-label">Branch</div>
11736 <div class="ws-mini-value" id="ws-branch">—</div>
11737 </div>
11738 </div>
11739 </div>
11740 </div>
11741
11742 <div class="layout">
11743 <aside class="side-stack">
11744 <section class="step-nav">
11745 <h3>Guided scan setup</h3>
11746 <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>
11747 <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>
11748 <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>
11749 <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>
11750
11751 <div class="step-steps-divider"></div>
11752
11753 <div class="step-nav-info" id="step-nav-info">
11754 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11755 <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>
11756 </div>
11757
11758 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11759 <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>
11760 <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>
11761 <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>
11762 </div>
11763
11764 <div class="quick-scan-divider"></div>
11765 <div class="quick-scan-section">
11766 <div class="quick-scan-label">No customization needed?</div>
11767 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11768 <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>
11769 Quick Scan
11770 </button>
11771 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11772 </div>
11773
11774 <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>
11775 </section>
11776
11777 </aside>
11778
11779 <section class="card">
11780 <div class="card-header">
11781 <div class="card-title-row">
11782 <div>
11783 <h1 class="card-title">Guided scan configuration</h1>
11784 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11785 </div>
11786 <div class="wizard-progress" aria-label="Scan setup progress">
11787 <div class="wizard-progress-top">
11788 <span class="wizard-progress-label">Setup progress</span>
11789 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11790 </div>
11791 <div class="wizard-progress-track">
11792 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11793 </div>
11794 </div>
11795 </div>
11796 </div>
11797 <div class="card-body">
11798 <form method="post" action="/analyze" id="analyze-form">
11799 <div class="wizard-step active" data-step="1">
11800 <div class="section">
11801 <div class="section-kicker">Step 1</div>
11802 <h2>Select project and preview scope</h2>
11803 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11804 <div class="field">
11805 <label for="path">Project path</label>
11806 {% if !git_repo.is_empty() %}
11807 <div class="git-source-banner">
11808 <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>
11809 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11810 <a href="/git-browser">← Back to Git Browser</a>
11811 </div>
11812 {% endif %}
11813 <div class="path-scope-grid">
11814 {% if !git_repo.is_empty() %}
11815 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11816 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11817 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11818 {% else %}
11819 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11820 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11821 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11822 {% endif %}
11823 <div class="path-scope-sep"></div>
11824 <div class="scope-legend-row">
11825 <span class="scope-legend-label">Scope legend:</span>
11826 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11827 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11828 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11829 </div>
11830 </div>
11831 {% if git_repo.is_empty() %}
11832 {% if server_mode %}
11833 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11834 ℹ️ Files are compressed and streamed — no fixed size limit.
11835 </div>
11836 {% endif %}
11837 <div class="path-info-row">
11838 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11839 <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>
11840 <span id="project-size-text">Project size: —</span>
11841 </button>
11842 </div>
11843 {% else %}
11844 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11845 {% endif %}
11846 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11847 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11848 </div>
11849
11850 <div class="scope-preview-divider" aria-hidden="true"></div>
11851
11852 <div id="preview-panel">
11853 <div class="preview-error">Loading preview...</div>
11854 </div>
11855 </div>
11856
11857 <div class="section" style="margin-top:14px;">
11858 <div class="preset-inline-row git-inline-row">
11859 <div class="toggle-card" style="margin:0;">
11860 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11861 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11862 <label class="checkbox">
11863 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11864 <div>
11865 <span>Detect and separate git submodules</span>
11866 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11867 </div>
11868 </label>
11869 </div>
11870 <div class="explainer-card prominent" style="margin:0;">
11871 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11872 <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>
11873 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11874 path = libs/core
11875 url = https://github.com/org/core.git
11876
11877[submodule "libs/ui"]
11878 path = libs/ui
11879 url = https://github.com/org/ui.git</div>
11880 </div>
11881 </div>
11882 </div>
11883
11884 <div class="section">
11885 <div class="field-grid">
11886 <div class="field">
11887 <label for="include_globs">Include globs</label>
11888 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
11889 <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>
11890 </div>
11891 <div class="field">
11892 <label for="exclude_globs">Exclude globs</label>
11893 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
11894 <div id="quick-exclude-chips" class="quick-excl-row">
11895 <span class="quick-excl-label">Quick add:</span>
11896 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11897 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11898 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11899 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11900 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11901 <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>
11902 </div>
11903 <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>
11904 </div>
11905 </div>
11906 <div class="glob-guidance-grid">
11907 <div class="glob-guidance-card">
11908 <strong>How to read them</strong>
11909 <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>
11910 </div>
11911 <div class="glob-guidance-card">
11912 <strong>Common include examples</strong>
11913 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11914 </div>
11915 <div class="glob-guidance-card">
11916 <strong>Common exclude examples</strong>
11917 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11918 </div>
11919 </div>
11920 </div>
11921
11922 <div class="section" style="margin-top:14px;">
11923 <div class="preset-inline-row git-inline-row">
11924 <div class="toggle-card" style="margin:0;">
11925 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11926 <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>
11927 <div class="field" style="margin:0;">
11928 <div class="input-group compact">
11929 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11930 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11931 </div>
11932 <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>
11933 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11934 </div>
11935 </div>
11936 <div class="explainer-card prominent" style="margin:0;">
11937 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11938 <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>
11939 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11940lcov --capture --directory . --output-file coverage/lcov.info
11941
11942# C / C++ — llvm-cov (LCOV)
11943llvm-profdata merge -sparse default.profraw -o default.profdata
11944llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11945
11946# C# — coverlet (Cobertura XML)
11947dotnet test --collect:"XPlat Code Coverage"
11948
11949# Python — pytest-cov (Cobertura XML)
11950pytest --cov --cov-report=xml
11951
11952# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11953./gradlew jacocoTestReport</div>
11954 </div>
11955 </div>
11956 </div>
11957
11958 <div class="wizard-actions">
11959 <div class="left"></div>
11960 <div class="right">
11961 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11962 </div>
11963 </div>
11964 </div>
11965
11966 <div class="wizard-step" data-step="2">
11967 <div class="section">
11968 <div class="section-kicker">Step 2</div>
11969 <h2>Choose counting behavior</h2>
11970 <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>
11971<div class="subsection-bar">Primary line classification</div>
11972 <div class="preset-kv-row">
11973 <div class="toggle-card mixed-line-card" style="margin:0;">
11974 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11975 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11976 <select id="mixed_line_policy" name="mixed_line_policy">
11977 <option value="code_only">Code only</option>
11978 <option value="code_and_comment">Code and comment</option>
11979 <option value="comment_only">Comment only</option>
11980 <option value="separate_mixed_category">Separate mixed category</option>
11981 </select>
11982 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11983 </div>
11984 <div class="explainer-card prominent" style="margin:0;">
11985 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11986 <div class="explainer-body" id="mixed-policy-description"></div>
11987 <div class="code-sample" id="mixed-policy-example"></div>
11988 </div>
11989 </div>
11990 </div>
11991
11992 <div class="subsection-bar">Additional scan rules</div>
11993 <div class="scan-rules-grid">
11994 <div class="preset-inline-row">
11995 <div class="toggle-card" style="margin:0;">
11996 <div class="field-help-title">Generated files</div>
11997 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11998 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11999 </div>
12000 <div class="explainer-card prominent" style="margin:0;">
12001 <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>
12002 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
12003# Files matching codegen patterns are excluded:
12004# *.generated.cs *.pb.go *.g.dart</div>
12005 </div>
12006 </div>
12007 <div class="preset-inline-row">
12008 <div class="toggle-card" style="margin:0;">
12009 <div class="field-help-title">Minified files</div>
12010 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
12011 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12012 </div>
12013 <div class="explainer-card prominent" style="margin:0;">
12014 <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>
12015 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
12016# Heuristic: very long lines + low whitespace ratio
12017# jquery.min.js bundle.min.css → skipped</div>
12018 </div>
12019 </div>
12020 <div class="preset-inline-row">
12021 <div class="toggle-card" style="margin:0;">
12022 <div class="field-help-title">Vendor directories</div>
12023 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
12024 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12025 </div>
12026 <div class="explainer-card prominent" style="margin:0;">
12027 <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>
12028 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
12029# Directories named vendor/ node_modules/ third_party/
12030# → entire subtree is excluded from totals</div>
12031 </div>
12032 </div>
12033 <div class="preset-inline-row">
12034 <div class="toggle-card" style="margin:0;">
12035 <div class="field-help-title">Lockfiles and manifests</div>
12036 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
12037 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
12038 </div>
12039 <div class="explainer-card prominent" style="margin:0;">
12040 <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>
12041 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
12042# Files like package-lock.json Cargo.lock yarn.lock
12043# → skipped unless this is enabled</div>
12044 </div>
12045 </div>
12046 <div class="preset-inline-row">
12047 <div class="toggle-card" style="margin:0;">
12048 <div class="field-help-title">Binary handling</div>
12049 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
12050 <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>
12051 </div>
12052 <div class="explainer-card prominent" style="margin:0;">
12053 <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>
12054 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
12055# Detected via long lines + low whitespace heuristic
12056# .png .exe .so → skipped silently</div>
12057 </div>
12058 </div>
12059 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
12060 <div class="toggle-card" style="margin:0;">
12061 <div class="field-help-title">Python docstrings</div>
12062 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
12063 <label class="checkbox">
12064 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
12065 <span>Count as comment-style lines</span>
12066 </label>
12067 </div>
12068 <div class="explainer-card prominent" style="margin:0;">
12069 <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>
12070 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
12071 </div>
12072 </div>
12073 </div>
12074 <div class="subsection-bar">IEEE 1045-1992 counting</div>
12075 <div class="scan-rules-grid">
12076 <div class="preset-inline-row">
12077 <div class="toggle-card" style="margin:0;">
12078 <div class="field-help-title">Continuation lines</div>
12079 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
12080 <select name="continuation_line_policy" id="continuation_line_policy">
12081 <option value="each_physical_line" selected>Each physical line (default)</option>
12082 <option value="collapse_to_logical">Collapse to logical line</option>
12083 </select>
12084 </div>
12085 <div class="explainer-card prominent" style="margin:0;">
12086 <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>
12087 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
12088 ((a) > (b) ? (a) : (b))
12089# each_physical_line → 2 SLOC
12090# collapse_to_logical → 1 SLOC</div>
12091 </div>
12092 </div>
12093 <div class="preset-inline-row">
12094 <div class="toggle-card" style="margin:0;">
12095 <div class="field-help-title">Block-comment blanks</div>
12096 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
12097 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
12098 <option value="count_as_comment" selected>Count as comment (default)</option>
12099 <option value="count_as_blank">Count as blank</option>
12100 </select>
12101 </div>
12102 <div class="explainer-card prominent" style="margin:0;">
12103 <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>
12104 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
12105 * Summary line
12106 * ← blank inside block comment
12107 * Detail line
12108 */
12109# count_as_comment → blank counts toward comments
12110# count_as_blank → blank counts toward blanks</div>
12111 </div>
12112 </div>
12113 <div class="preset-inline-row">
12114 <div class="toggle-card" style="margin:0;">
12115 <div class="field-help-title">Compiler directives</div>
12116 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
12117 <select name="count_compiler_directives" id="count_compiler_directives">
12118 <option value="enabled" selected>Include in code SLOC (default)</option>
12119 <option value="disabled">Exclude from code SLOC</option>
12120 </select>
12121 </div>
12122 <div class="explainer-card prominent" style="margin:0;">
12123 <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>
12124 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
12125#define BUF 256 ← compiler directive
12126int main() { … } ← code
12127# enabled → 3 code SLOC
12128# disabled → 1 code SLOC + 2 directive lines</div>
12129 </div>
12130 </div>
12131 </div>
12132
12133 <div class="always-tracked-tip">
12134 <div class="always-tracked-tip-icon">ℹ</div>
12135 <div class="always-tracked-tip-body">
12136 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
12137 <h4>Comment and blank-line basics & Lines on the boundary</h4>
12138 <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>
12139 </div>
12140 </div>
12141
12142 <div class="wizard-actions">
12143 <div class="left">
12144 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
12145 </div>
12146 <div class="right">
12147 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
12148 </div>
12149 </div>
12150 </div>
12151
12152 <div class="wizard-step" data-step="3">
12153 <div class="section">
12154 <div class="section-kicker">Step 3</div>
12155 <h2>Output and report identity</h2>
12156 <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>
12157 <div class="preset-kv-row">
12158 <div class="toggle-card" style="margin:0;">
12159 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
12160 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
12161 <select id="scan_preset">
12162 <option value="balanced">Balanced local scan</option>
12163 <option value="code_focused">Code focused</option>
12164 <option value="comment_audit">Comment audit</option>
12165 <option value="deep_review">Deep review</option>
12166 </select>
12167 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
12168 </div>
12169 <div class="explainer-card">
12170 <div class="field-help-title">Selected scan preset</div>
12171 <div class="explainer-body" id="scan-preset-description"></div>
12172 <div class="preset-summary-row" id="scan-preset-summary"></div>
12173 <div class="code-sample" id="scan-preset-example"></div>
12174 <div class="preset-note" id="scan-preset-note"></div>
12175 </div>
12176 </div>
12177 <hr class="step3-separator" />
12178 <div class="preset-kv-row">
12179 <div class="toggle-card" style="margin:0;">
12180 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
12181 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
12182 <select id="artifact_preset">
12183 <option value="review">Review bundle</option>
12184 <option value="full">Full bundle</option>
12185 <option value="html_only">HTML only</option>
12186 <option value="machine">Machine bundle</option>
12187 </select>
12188 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
12189 </div>
12190 <div class="explainer-card">
12191 <div class="field-help-title">Selected artifact preset</div>
12192 <div class="explainer-body" id="artifact-preset-description"></div>
12193 <div class="preset-summary-row" id="artifact-preset-summary"></div>
12194 <div class="code-sample" id="artifact-preset-example"></div>
12195 </div>
12196 </div>
12197 </div>
12198
12199 <div class="section section-spacer-top">
12200 <div class="output-field-row">
12201 <div class="field">
12202 <label for="output_dir">Output directory</label>
12203 {% if server_mode %}
12204 <div class="input-group compact">
12205 <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);" />
12206 </div>
12207 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
12208 {% else %}
12209 <div class="input-group compact">
12210 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
12211 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
12212 <button type="button" class="mini-button" id="use-default-output">Use default</button>
12213 </div>
12214 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
12215 {% endif %}
12216 </div>
12217 <div class="output-field-aside">
12218 <strong>Where reports land</strong>
12219 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.
12220 </div>
12221 </div>
12222 </div>
12223
12224 <div class="section section-spacer-top">
12225 <div class="output-field-row">
12226 <div class="field">
12227 <label for="report_title">Report title</label>
12228 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
12229 <div class="hint">Appears in HTML and PDF output headers.</div>
12230 </div>
12231 <div class="output-field-aside">
12232 <strong>Shown in exported artifacts</strong>
12233 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.
12234 </div>
12235 </div>
12236 </div>
12237
12238 <div class="section section-spacer-top">
12239 <div class="output-field-row">
12240 <div class="field">
12241 <label for="report_header_footer">Report header / footer</label>
12242 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
12243 <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>
12244 </div>
12245 <div class="output-field-aside">
12246 <strong>Page-level identification</strong>
12247 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.
12248 </div>
12249 </div>
12250 </div>
12251
12252 <div class="section">
12253 <div class="section-kicker">Artifacts</div>
12254 <div class="artifact-grid" style="margin-bottom:24px;">
12255 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
12256 <div class="marker">✓</div>
12257 <div class="artifact-icon">H</div>
12258 <h4>HTML report</h4>
12259 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
12260 <div class="artifact-tags">
12261 <span class="soft-chip">Best for visual review</span>
12262 <span class="soft-chip">Embeddable preview</span>
12263 </div>
12264 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
12265 </div>
12266 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
12267 <div class="marker">✓</div>
12268 <div class="artifact-icon">P</div>
12269 <h4>PDF export</h4>
12270 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
12271 <div class="artifact-tags">
12272 <span class="soft-chip">Portable snapshot</span>
12273 <span class="soft-chip">Good for handoff</span>
12274 </div>
12275 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
12276 </div>
12277 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
12278 <div style="position:absolute;inset:0;border-radius:inherit;background:radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.13) 100%);pointer-events:none;z-index:2;"></div>
12279 <div class="marker">✓</div>
12280 <div class="artifact-icon" style="color:var(--muted);">J</div>
12281 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
12282 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
12283 <div class="artifact-tags">
12284 <span class="soft-chip">Required for compare</span>
12285 <span class="soft-chip">Auto-enabled</span>
12286 </div>
12287 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
12288 </div>
12289 </div>
12290 <div class="hint" style="margin-top:12px;">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</div>
12291 </div>
12292
12293 <div class="wizard-actions">
12294 <div class="left">
12295 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
12296 </div>
12297 <div class="right">
12298 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
12299 </div>
12300 </div>
12301 </div>
12302
12303 <div class="wizard-step" data-step="4">
12304 <div class="section">
12305 <div class="section-kicker">Step 4</div>
12306 <h2>Review selections and run</h2>
12307 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
12308 <div class="review-grid">
12309 <div class="review-card highlight">
12310 <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>
12311 <ul id="review-scan-summary"></ul>
12312 </div>
12313 <div class="review-card highlight">
12314 <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>
12315 <ul id="review-count-summary"></ul>
12316 </div>
12317 <div class="review-card">
12318 <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>
12319 <ul id="review-artifact-summary"></ul>
12320 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
12321 </div>
12322 <div class="review-card">
12323 <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>
12324 <ul id="review-preview-summary"></ul>
12325 </div>
12326 </div>
12327 </div>
12328
12329 <div class="wizard-actions">
12330 <div class="left">
12331 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12332 </div>
12333 <div class="right">
12334 <button type="submit" id="submit-button" class="primary">Run analysis</button>
12335 </div>
12336 </div>
12337 </div>
12338 {% if server_mode %}
12339 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12340 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12341 {% endif %}
12342 </form>
12343 </div>
12344 </section>
12345 </div>
12346 </div>
12347
12348 <script nonce="{{ csp_nonce }}">
12349 (function () {
12350 function startScanPhase() {
12351 var phaseEl = document.getElementById("scan-phase");
12352 if (!phaseEl) return;
12353 var phases = [
12354 "Discovering files...",
12355 "Decoding file encodings...",
12356 "Detecting languages...",
12357 "Analyzing source lines...",
12358 "Applying counting policies...",
12359 "Aggregating results...",
12360 "Rendering report..."
12361 ];
12362 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12363 var i = 0;
12364 function next() {
12365 phaseEl.style.opacity = "0";
12366 setTimeout(function () {
12367 phaseEl.textContent = phases[i];
12368 phaseEl.style.opacity = "0.85";
12369 var delay = durations[i] || 1800;
12370 i++;
12371 if (i < phases.length) { setTimeout(next, delay); }
12372 }, 200);
12373 }
12374 next();
12375 }
12376
12377 var form = document.getElementById("analyze-form");
12378 var loading = document.getElementById("loading");
12379 var submitButton = document.getElementById("submit-button");
12380 var pathInput = document.getElementById("path");
12381 var GIT_MODE = !!(pathInput && pathInput.readOnly);
12382 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12383 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12384 var outputDirInput = document.getElementById("output_dir");
12385 var reportTitleInput = document.getElementById("report_title");
12386 var previewPanel = document.getElementById("preview-panel");
12387 var refreshButton = document.getElementById("refresh-preview");
12388 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12389 var useSamplePath = document.getElementById("use-sample-path");
12390 var useDefaultOutput = document.getElementById("use-default-output");
12391 var browsePath = document.getElementById("browse-path");
12392 var browseOutputDir = document.getElementById("browse-output-dir");
12393 var browseCoverage = document.getElementById("browse-coverage");
12394 var coverageInput = document.getElementById("coverage_file");
12395 var covScanStatus = document.getElementById("cov-scan-status");
12396 var coverageSuggestTimer = null;
12397 var covAutoFilled = false;
12398 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12399 function fmtBytes(b) {
12400 b = Number(b) || 0;
12401 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12402 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12403 if (b >= 1024) return Math.round(b / 1024) + ' KB';
12404 return b + ' B';
12405 }
12406 var themeToggle = document.getElementById("theme-toggle");
12407
12408 function showBannerToast(msg, isError, opts) {
12409 opts = opts || {};
12410 var t = document.createElement('div');
12411 t.className = isError ? 'toast-error' : 'toast-success';
12412 var topPos = opts.top ? '80px' : null;
12413 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12414 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12415 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12416 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12417 if (opts.icon) {
12418 var inner = document.createElement('span');
12419 inner.innerHTML = opts.icon + ' ';
12420 t.appendChild(inner);
12421 }
12422 t.appendChild(document.createTextNode(msg));
12423 document.body.appendChild(t);
12424 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12425 }
12426 var mixedLinePolicy = document.getElementById("mixed_line_policy");
12427 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12428 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12429 var scanPreset = document.getElementById("scan_preset");
12430 var artifactPreset = document.getElementById("artifact_preset");
12431 var includeGlobsInput = document.getElementById("include_globs");
12432 var excludeGlobsInput = document.getElementById("exclude_globs");
12433
12434 // Quick-exclude chips — append pattern to exclude_globs textarea.
12435 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12436 chip.addEventListener("click", function() {
12437 var pattern = chip.getAttribute("data-pattern") || "";
12438 if (!pattern || !excludeGlobsInput) return;
12439 var current = excludeGlobsInput.value.trim();
12440 // For the "skip all" chip, replace any existing dep patterns cleanly.
12441 var patterns = pattern.split("\n");
12442 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12443 var added = false;
12444 patterns.forEach(function(p) {
12445 p = p.trim();
12446 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12447 });
12448 if (added) {
12449 excludeGlobsInput.value = lines.join("\n");
12450 excludeGlobsInput.dispatchEvent(new Event("input"));
12451 }
12452 chip.classList.add("active");
12453 });
12454 });
12455
12456 var liveReportTitle = document.getElementById("live-report-title");
12457 var navProjectPill = document.getElementById("nav-project-pill");
12458 var navProjectTitle = document.getElementById("nav-project-title");
12459 var reportTitlePreview = null;
12460 var wizardProgressFill = document.getElementById("wizard-progress-fill");
12461 var wizardProgressValue = document.getElementById("wizard-progress-value");
12462 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12463 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12464 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12465 var reportTitleTouched = false;
12466 var currentStep = 1;
12467 var previewTimer = null;
12468 var quickScanBtn = document.getElementById("quick-scan-btn");
12469
12470 function dismissAnalysisModal() {
12471 if (loading) loading.classList.remove("active");
12472 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12473 var el = document.getElementById(id);
12474 if (el) el.classList.add("hidden");
12475 });
12476 var cancelBtn = document.getElementById("lc-cancel-btn");
12477 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12478 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12479 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12480 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12481 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12482 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12483 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12484 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12485 }
12486
12487 var lcDismissBtn = document.getElementById("lc-dismiss");
12488 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12489
12490 function startAsyncAnalysis(formData) {
12491 var gitRepo = (formData.get("git_repo") || "").toString();
12492 var gitRef = (formData.get("git_ref") || "").toString();
12493 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12494 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12495
12496 var pathEl = document.getElementById("lc-path");
12497 if (pathEl) pathEl.textContent = displayPath;
12498
12499 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12500 var el = document.getElementById(id);
12501 if (el) el.classList.add("hidden");
12502 });
12503 var cancelBtn = document.getElementById("lc-cancel-btn");
12504 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12505 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12506 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12507 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12508 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12509 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
12510
12511 if (loading) loading.classList.add("active");
12512
12513 var startTime = Date.now();
12514 var elapsedTimer = setInterval(function() {
12515 var s = Math.floor((Date.now() - startTime) / 1000);
12516 var el = document.getElementById("lc-elapsed");
12517 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12518 }, 1000);
12519
12520 var warnShown = false, pollRetries = 0, activeWaitId = null;
12521
12522 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
12523
12524 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12525
12526 function lcShowCancelled() {
12527 clearInterval(elapsedTimer);
12528 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12529 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12530 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12531 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12532 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12533 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12534 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12535 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12536 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12537 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12538 }
12539
12540 var lcCancelBtn = document.getElementById("lc-cancel-btn");
12541 if (lcCancelBtn) {
12542 lcCancelBtn.onclick = function() {
12543 if (!activeWaitId) { dismissAnalysisModal(); return; }
12544 lcCancelBtn.disabled = true;
12545 lcCancelBtn.textContent = "Cancelling…";
12546 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12547 .then(function() { lcShowCancelled(); })
12548 .catch(function() { lcShowCancelled(); });
12549 };
12550 }
12551
12552 function lcShowError(msg) {
12553 clearInterval(elapsedTimer);
12554 lcSetPhase("Failed");
12555 var msgEl = document.getElementById("lc-err-msg");
12556 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12557 var errEl = document.getElementById("lc-err");
12558 var actEl = document.getElementById("lc-actions");
12559 if (errEl) errEl.classList.remove("hidden");
12560 if (actEl) actEl.classList.remove("hidden");
12561 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12562 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12563 }
12564
12565 function lcPoll(waitId) {
12566 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12567 .then(function(r) {
12568 if (!r.ok) throw new Error("HTTP " + r.status);
12569 return r.json();
12570 })
12571 .then(function(data) {
12572 pollRetries = 0;
12573 if (data.state === "complete") {
12574 clearInterval(elapsedTimer);
12575 lcSetPhase("Done");
12576 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12577 } else if (data.state === "failed") {
12578 lcShowError(data.message);
12579 } else if (data.state === "cancelled") {
12580 lcShowCancelled();
12581 } else {
12582 var s = Math.floor((Date.now() - startTime) / 1000);
12583 if (s > 90 && !warnShown) {
12584 warnShown = true;
12585 var w = document.getElementById("lc-warn");
12586 if (w) w.classList.remove("hidden");
12587 }
12588 lcSetPhase(data.phase || "Running");
12589 var fd = data.files_done || 0, ft = data.files_total || 0;
12590 if (ft > 0) {
12591 var card = document.getElementById("lc-files-card");
12592 if (card) card.classList.remove("hidden");
12593 var el = document.getElementById("lc-files");
12594 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
12595 }
12596 setTimeout(function() { lcPoll(waitId); }, 1500);
12597 }
12598 })
12599 .catch(function() {
12600 pollRetries++;
12601 if (pollRetries >= 5) {
12602 lcShowError("Lost connection to server. Reload to check status.");
12603 } else {
12604 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12605 }
12606 });
12607 }
12608
12609 var params = new URLSearchParams(formData);
12610 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12611 .then(function(r) {
12612 var waitId = r.headers.get("x-wait-id");
12613 if (!waitId) { window.location.href = "/scan"; return; }
12614 activeWaitId = waitId;
12615 setTimeout(function() { lcPoll(waitId); }, 1500);
12616 })
12617 .catch(function(err) {
12618 lcShowError("Could not reach server: " + (err.message || err));
12619 });
12620 }
12621
12622 if (quickScanBtn) {
12623 quickScanBtn.addEventListener("click", function () {
12624 var pathVal = pathInput ? pathInput.value.trim() : "";
12625 if (!pathVal) {
12626 alert("Please enter or browse to a project path first.");
12627 return;
12628 }
12629 quickScanBtn.disabled = true;
12630 quickScanBtn.textContent = "Scanning...";
12631 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12632 startAsyncAnalysis(new FormData(form));
12633 });
12634 }
12635
12636 var mixedPolicyInfo = {
12637 code_only: {
12638 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.",
12639 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'
12640 },
12641 code_and_comment: {
12642 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.",
12643 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'
12644 },
12645 comment_only: {
12646 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.",
12647 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'
12648 },
12649 separate_mixed_category: {
12650 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.",
12651 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'
12652 }
12653 };
12654
12655 var scanPresetInfo = {
12656 balanced: {
12657 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.",
12658 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12659 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12660 note: "Best when you want a stable local overview before making deeper adjustments.",
12661 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12662 },
12663 code_focused: {
12664 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12665 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12666 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12667 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12668 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12669 },
12670 comment_audit: {
12671 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12672 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12673 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12674 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12675 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12676 },
12677 deep_review: {
12678 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12679 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12680 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12681 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12682 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12683 }
12684 };
12685
12686 var artifactPresetInfo = {
12687 review: {
12688 description: "Review bundle enables HTML and PDF so you can inspect the result in-browser and still save a portable snapshot for sharing or archiving.",
12689 chips: ["HTML", "PDF"],
12690 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12691 },
12692 full: {
12693 description: "Full bundle enables HTML, PDF, and JSON. It is the best choice when you want both human-readable outputs and a machine-friendly artifact for later processing.",
12694 chips: ["HTML", "PDF", "JSON"],
12695 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12696 },
12697 html_only: {
12698 description: "HTML only keeps the run lightweight and browser-first. It is ideal for quick local inspection when you do not need a fixed snapshot or automation output.",
12699 chips: ["HTML only", "Fast local review"],
12700 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12701 },
12702 machine: {
12703 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12704 chips: ["HTML", "JSON"],
12705 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12706 }
12707 };
12708
12709 function applyTheme(theme) {
12710 if (theme === "dark") document.body.classList.add("dark-theme");
12711 else document.body.classList.remove("dark-theme");
12712 }
12713
12714 function loadSavedTheme() {
12715 var saved = null;
12716 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12717 applyTheme(saved === "dark" ? "dark" : "light");
12718 }
12719
12720 function updateScrollProgress() {
12721 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12722 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12723 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12724 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12725 var step = Math.min(Math.max(currentStep, 1), 4);
12726 var base = stepBase[step];
12727 var end = stepEnd[step];
12728
12729 var scrollFrac = 0;
12730 var activePanel = document.querySelector(".wizard-step.active");
12731 if (activePanel) {
12732 var scrollTop = window.scrollY || window.pageYOffset || 0;
12733 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12734 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12735 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12736 var scrolled = scrollTop + viewH - panelTop;
12737 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12738 }
12739
12740 var percent = Math.round(base + (end - base) * scrollFrac);
12741 percent = Math.min(end, Math.max(base, percent));
12742 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12743 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12744 }
12745
12746 function updateWizardProgress() {
12747 updateScrollProgress();
12748 }
12749
12750 var stepDescriptions = [
12751 "Choose a project folder, apply scope filters, and preview which files will be counted.",
12752 "Configure how mixed code-plus-comment lines and docstrings are classified.",
12753 "Pick your output formats, scan preset, and where reports are saved.",
12754 "Review all settings and launch the analysis."
12755 ];
12756
12757 function updateStepNav(step) {
12758 var infoLabel = document.getElementById("step-nav-info-label");
12759 var infoDesc = document.getElementById("step-nav-info-desc");
12760 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12761 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
12762 }
12763
12764 function updateSidebarSummary() {
12765 var sumPath = document.getElementById("sum-path");
12766 var sumPreset = document.getElementById("sum-preset");
12767 var sumOutput = document.getElementById("sum-output");
12768 var sidebarSummary = document.getElementById("sidebar-summary");
12769 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12770 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
12771 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12772 if (sumPath) sumPath.textContent = pathVal || "—";
12773 if (sumPreset) sumPreset.textContent = presetVal || "—";
12774 if (sumOutput) sumOutput.textContent = outputVal || "—";
12775 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12776 }
12777
12778 function setStep(step, pushHistory) {
12779 currentStep = step;
12780 stepPanels.forEach(function (panel) {
12781 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12782 });
12783 stepButtons.forEach(function (button) {
12784 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12785 });
12786 var layoutEl = document.querySelector(".layout");
12787 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12788 updateWizardProgress();
12789 updateStepNav(step);
12790 stepButtons.forEach(function(btn) {
12791 var t = Number(btn.getAttribute("data-step-target"));
12792 btn.classList.toggle("done", t < step);
12793 });
12794 updateSidebarSummary();
12795
12796 if (pushHistory !== false) {
12797 try {
12798 history.pushState({ wizardStep: step }, "", "#step" + step);
12799 } catch (e) {}
12800 }
12801
12802 window.scrollTo({ top: 0, behavior: "instant" });
12803 }
12804
12805 window.addEventListener("popstate", function (e) {
12806 if (e.state && e.state.wizardStep) {
12807 setStep(e.state.wizardStep, false);
12808 } else {
12809 var hashMatch = location.hash.match(/^#step([1-4])$/);
12810 if (hashMatch) setStep(Number(hashMatch[1]), false);
12811 }
12812 });
12813
12814 function inferTitleFromPath(value) {
12815 if (!value) return "project";
12816 var cleaned = value.replace(/[\/\\]+$/, "");
12817 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12818 return parts.length ? parts[parts.length - 1] : value;
12819 }
12820
12821 function updateReportTitleFromPath() {
12822 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12823 if (!reportTitleTouched) {
12824 reportTitleInput.value = inferred;
12825 }
12826 var title = reportTitleInput.value || inferred;
12827 if (liveReportTitle) liveReportTitle.textContent = title;
12828 if (reportTitlePreview) reportTitlePreview.textContent = title;
12829 document.title = "OxideSLOC | " + title;
12830
12831 var projectPath = (pathInput.value || "").trim();
12832 if (navProjectPill && navProjectTitle) {
12833 if (projectPath.length > 0) {
12834 navProjectTitle.textContent = inferred;
12835 navProjectPill.classList.add("visible");
12836 } else {
12837 navProjectTitle.textContent = "";
12838 navProjectPill.classList.remove("visible");
12839 }
12840 }
12841 }
12842
12843 function updateMixedPolicyUI() {
12844 var key = mixedLinePolicy.value || "code_only";
12845 var info = mixedPolicyInfo[key];
12846 document.getElementById("mixed-policy-description").textContent = info.description;
12847 document.getElementById("mixed-policy-example").textContent = info.example;
12848 }
12849
12850 function updatePythonDocstringUI() {
12851 var checked = !!pythonDocstrings.checked;
12852 document.getElementById("python-docstring-example").textContent = checked
12853 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
12854 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
12855 document.getElementById("python-docstring-live-help").textContent = checked
12856 ? "Enabled: docstrings contribute to comment-style totals."
12857 : "Disabled: docstrings are not counted as comment content.";
12858 }
12859
12860 function renderPresetChips(targetId, chips) {
12861 var target = document.getElementById(targetId);
12862 if (!target) return;
12863 target.innerHTML = (chips || []).map(function (chip) {
12864 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12865 }).join('');
12866 }
12867
12868 function updatePresetDescriptions() {
12869 var scanInfo = scanPresetInfo[scanPreset.value];
12870 var artifactInfo = artifactPresetInfo[artifactPreset.value];
12871 document.getElementById("scan-preset-description").textContent = scanInfo.description;
12872 document.getElementById("scan-preset-example").textContent = scanInfo.example;
12873 document.getElementById("scan-preset-note").textContent = scanInfo.note;
12874 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12875 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12876 renderPresetChips("scan-preset-summary", scanInfo.chips);
12877 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12878 }
12879
12880 function applyScanPreset() {
12881 var info = scanPresetInfo[scanPreset.value];
12882 if (!info || !info.apply) return;
12883 mixedLinePolicy.value = info.apply.mixed;
12884 pythonDocstrings.checked = !!info.apply.docstrings;
12885 document.getElementById("generated_file_detection").value = info.apply.generated;
12886 document.getElementById("minified_file_detection").value = info.apply.minified;
12887 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12888 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12889 document.getElementById("binary_file_behavior").value = info.apply.binary;
12890 updateMixedPolicyUI();
12891 updatePythonDocstringUI();
12892 }
12893
12894 function applyArtifactPreset() {
12895 var enabled = { html: false, pdf: false };
12896 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12897 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12898 if (artifactPreset.value === "html_only") { enabled.html = true; }
12899 if (artifactPreset.value === "machine") { enabled.html = true; }
12900
12901 artifactCards.forEach(function (card) {
12902 var artifact = card.getAttribute("data-artifact");
12903 if (artifact === "json") return;
12904 var checked = !!enabled[artifact];
12905 var checkbox = card.querySelector(".artifact-checkbox");
12906 checkbox.checked = checked;
12907 card.classList.toggle("selected", checked);
12908 });
12909 }
12910
12911 function toggleArtifactCard(card) {
12912 var checkbox = card.querySelector(".artifact-checkbox");
12913 checkbox.checked = !checkbox.checked;
12914 card.classList.toggle("selected", checkbox.checked);
12915 }
12916
12917 function updateReview() {
12918 var scanSummary = document.getElementById("review-scan-summary");
12919 var countSummary = document.getElementById("review-count-summary");
12920 var artifactSummary = document.getElementById("review-artifact-summary");
12921 var outputSummary = document.getElementById("review-output-summary");
12922 var previewSummary = document.getElementById("review-preview-summary");
12923 var readinessSummary = document.getElementById("review-readiness-summary");
12924 var includeText = document.getElementById("include_globs").value.trim();
12925 var excludeText = document.getElementById("exclude_globs").value.trim();
12926 var sidePathPreview = document.getElementById("side-path-preview");
12927 var sideOutputPreview = document.getElementById("side-output-preview");
12928 var sideTitlePreview = document.getElementById("side-title-preview");
12929
12930 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12931 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12932 if (sideTitlePreview) {
12933 var rt = document.getElementById("report_title");
12934 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12935 }
12936
12937 scanSummary.innerHTML = ""
12938 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12939 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12940 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12941
12942 countSummary.innerHTML = ""
12943 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12944 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12945 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12946 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12947 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12948 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12949 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12950 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12951
12952 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12953 artifactSummary.innerHTML = ""
12954 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12955 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12956
12957 outputSummary.innerHTML = ""
12958 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12959 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12960
12961 if (previewSummary) {
12962 if (GIT_MODE) {
12963 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>';
12964 } else {
12965 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12966 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12967 var statMap = {};
12968 statButtons.forEach(function (button) {
12969 var valueNode = button.querySelector('.scope-stat-value');
12970 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12971 });
12972 previewSummary.innerHTML = ''
12973 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12974 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12975 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12976 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12977 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12978 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12979
12980 if (readinessSummary) {
12981 var selectedArtifactsCount = selectedArtifacts.length;
12982 readinessSummary.innerHTML = ''
12983 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12984 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12985 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12986 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12987 }
12988 } // end else (non-GIT_MODE)
12989 }
12990 }
12991
12992 function escapeHtml(value) {
12993 return String(value)
12994 .replace(/&/g, "&")
12995 .replace(/</g, "<")
12996 .replace(/>/g, ">")
12997 .replace(/"/g, """)
12998 .replace(/'/g, "'");
12999 }
13000
13001 function isPythonVisible() {
13002 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
13003 }
13004
13005 function syncPythonVisibility() {
13006 var html = previewPanel.textContent || "";
13007 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
13008 pythonWraps.forEach(function (node) {
13009 node.classList.toggle("hidden", !hasPython);
13010 });
13011 }
13012
13013 function attachPreviewInteractions() {
13014 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
13015 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
13016 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
13017 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
13018 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
13019 var searchInput = previewPanel.querySelector("#explorer-search");
13020 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
13021 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
13022 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
13023 var activeFilter = "all";
13024 var activeLanguage = "";
13025 var searchTerm = "";
13026 var currentSortKey = null;
13027 var currentSortOrder = "asc";
13028 var childRows = {};
13029
13030 rows.forEach(function (row) {
13031 var parentId = row.getAttribute("data-parent-id") || "";
13032 var rowId = row.getAttribute("data-row-id") || "";
13033 if (!childRows[parentId]) childRows[parentId] = [];
13034 childRows[parentId].push(rowId);
13035 });
13036
13037 function rowById(id) {
13038 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
13039 }
13040
13041 function hasCollapsedAncestor(row) {
13042 var parentId = row.getAttribute("data-parent-id");
13043 while (parentId) {
13044 var parent = rowById(parentId);
13045 if (!parent) break;
13046 if (parent.getAttribute("data-expanded") === "false") return true;
13047 parentId = parent.getAttribute("data-parent-id");
13048 }
13049 return false;
13050 }
13051
13052 function updateToggleGlyph(row) {
13053 var toggle = row.querySelector(".tree-toggle");
13054 if (!toggle) return;
13055 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
13056 }
13057
13058 function rowSortValue(row, key) {
13059 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
13060 }
13061
13062 function updateSortButtons() {
13063 sortButtons.forEach(function (button) {
13064 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
13065 var indicator = button.querySelector(".tree-sort-indicator");
13066 button.classList.toggle("active", isActive);
13067 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
13068 if (indicator) {
13069 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
13070 }
13071 });
13072 }
13073
13074 function sortSiblingRows() {
13075 if (!treeContainer) {
13076 updateSortButtons();
13077 return;
13078 }
13079
13080 var rowMap = {};
13081 var childrenMap = {};
13082 rows.forEach(function (row) {
13083 var rowId = row.getAttribute("data-row-id");
13084 var parentId = row.getAttribute("data-parent-id") || "";
13085 rowMap[rowId] = row;
13086 if (!childrenMap[parentId]) childrenMap[parentId] = [];
13087 childrenMap[parentId].push(rowId);
13088 });
13089
13090 Object.keys(childrenMap).forEach(function (parentId) {
13091 if (!parentId) return;
13092 childrenMap[parentId].sort(function (a, b) {
13093 var rowA = rowMap[a];
13094 var rowB = rowMap[b];
13095 if (!currentSortKey) {
13096 return Number(a) - Number(b);
13097 }
13098 var valueA = rowSortValue(rowA, currentSortKey);
13099 var valueB = rowSortValue(rowB, currentSortKey);
13100 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
13101 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
13102 var fallbackA = rowSortValue(rowA, "name");
13103 var fallbackB = rowSortValue(rowB, "name");
13104 if (fallbackA < fallbackB) return -1;
13105 if (fallbackA > fallbackB) return 1;
13106 return Number(a) - Number(b);
13107 });
13108 });
13109
13110 var orderedIds = [];
13111 function pushChildren(parentId) {
13112 (childrenMap[parentId] || []).forEach(function (childId) {
13113 orderedIds.push(childId);
13114 pushChildren(childId);
13115 });
13116 }
13117
13118 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
13119 orderedIds.push(topId);
13120 pushChildren(topId);
13121 });
13122
13123 orderedIds.forEach(function (id) {
13124 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
13125 });
13126 updateSortButtons();
13127 }
13128
13129 function updateLanguageButtons() {
13130 languageButtons.forEach(function (button) {
13131 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
13132 var isActive = languageValue === activeLanguage;
13133 button.classList.toggle("active", isActive);
13134 });
13135 }
13136
13137 function rowSelfMatches(row) {
13138 var kind = row.getAttribute("data-kind");
13139 var status = row.getAttribute("data-status");
13140 var language = (row.getAttribute("data-language") || "").toLowerCase();
13141 var name = row.getAttribute("data-name-lower") || "";
13142 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
13143 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
13144 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
13145 var passesLanguage = !activeLanguage || language === activeLanguage;
13146 return passesFilter && passesSearch && passesLanguage;
13147 }
13148
13149 function hasMatchingDescendant(rowId) {
13150 return (childRows[rowId] || []).some(function (childId) {
13151 var childRow = rowById(childId);
13152 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
13153 });
13154 }
13155
13156 function rowMatches(row) {
13157 if (rowSelfMatches(row)) return true;
13158 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
13159 }
13160
13161 function resetViewState() {
13162 activeFilter = "all";
13163 activeLanguage = "";
13164 searchTerm = "";
13165 currentSortKey = null;
13166 currentSortOrder = "asc";
13167 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13168 if (searchInput) searchInput.value = "";
13169 if (filterSelect) filterSelect.value = "all";
13170 updateLanguageButtons();
13171 }
13172
13173 function applyVisibility() {
13174 rows.forEach(function (row) {
13175 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
13176 row.classList.toggle("hidden-by-filter", !visible);
13177 row.style.display = visible ? "grid" : "none";
13178 });
13179 buttons.forEach(function (button) {
13180 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
13181 });
13182 if (filterSelect) filterSelect.value = activeFilter;
13183 }
13184
13185 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
13186 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
13187 var originalStats = {};
13188 buttons.forEach(function (btn) {
13189 var f = btn.getAttribute('data-filter');
13190 var v = btn.querySelector('.scope-stat-value');
13191 if (f && v) originalStats[f] = v.textContent;
13192 });
13193
13194 function applySubmoduleStats(statsJson) {
13195 try {
13196 var s = JSON.parse(statsJson);
13197 buttons.forEach(function (btn) {
13198 var f = btn.getAttribute('data-filter');
13199 var v = btn.querySelector('.scope-stat-value');
13200 if (!v) return;
13201 if (f === 'dir') v.textContent = s.dirs;
13202 else if (f === 'file') v.textContent = s.files;
13203 else if (f === 'supported') v.textContent = s.supported;
13204 else if (f === 'skipped') v.textContent = s.skipped;
13205 else if (f === 'unsupported') v.textContent = s.unsupported;
13206 });
13207 } catch (e) {}
13208 }
13209
13210 function restoreBaseRepoStats() {
13211 buttons.forEach(function (btn) {
13212 var f = btn.getAttribute('data-filter');
13213 var v = btn.querySelector('.scope-stat-value');
13214 if (v && originalStats[f]) v.textContent = originalStats[f];
13215 });
13216 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13217 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
13218 }
13219
13220 submoduleChips.forEach(function (chip) {
13221 chip.addEventListener('click', function () {
13222 var statsJson = chip.getAttribute('data-sub-stats');
13223 if (!statsJson) return;
13224 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13225 chip.classList.add('active');
13226 applySubmoduleStats(statsJson);
13227 if (baseRepoBtn) baseRepoBtn.style.display = '';
13228 });
13229 });
13230
13231 if (baseRepoBtn) {
13232 baseRepoBtn.addEventListener('click', function () {
13233 restoreBaseRepoStats();
13234 resetViewState();
13235 sortSiblingRows();
13236 applyVisibility();
13237 });
13238 }
13239
13240 buttons.forEach(function (button) {
13241 button.addEventListener("click", function () {
13242 var filterValue = button.getAttribute("data-filter") || "all";
13243 if (filterValue === "reset-view") {
13244 restoreBaseRepoStats();
13245 resetViewState();
13246 sortSiblingRows();
13247 applyVisibility();
13248 return;
13249 }
13250 activeFilter = filterValue;
13251 applyVisibility();
13252 });
13253 });
13254
13255 rows.forEach(function (row) {
13256 updateToggleGlyph(row);
13257 var toggle = row.querySelector(".tree-toggle");
13258 if (toggle) {
13259 toggle.addEventListener("click", function () {
13260 var expanded = row.getAttribute("data-expanded") !== "false";
13261 row.setAttribute("data-expanded", expanded ? "false" : "true");
13262 updateToggleGlyph(row);
13263 applyVisibility();
13264 });
13265 }
13266 });
13267
13268 actionButtons.forEach(function (button) {
13269 button.addEventListener("click", function () {
13270 var action = button.getAttribute("data-explorer-action");
13271 if (action === "expand-all") {
13272 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13273 } else if (action === "collapse-all") {
13274 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
13275 } else if (action === "clear-filters") {
13276 resetViewState();
13277 }
13278 sortSiblingRows();
13279 applyVisibility();
13280 });
13281 });
13282
13283 if (filterSelect) {
13284 filterSelect.addEventListener("change", function () {
13285 activeFilter = filterSelect.value || "all";
13286 applyVisibility();
13287 });
13288 }
13289
13290 languageButtons.forEach(function (button) {
13291 button.addEventListener("click", function () {
13292 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
13293 updateLanguageButtons();
13294 applyVisibility();
13295 });
13296 });
13297
13298 sortButtons.forEach(function (button) {
13299 button.addEventListener("click", function () {
13300 var sortKey = button.getAttribute("data-sort-key");
13301 if (currentSortKey === sortKey) {
13302 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
13303 } else {
13304 currentSortKey = sortKey;
13305 currentSortOrder = "asc";
13306 }
13307 sortSiblingRows();
13308 applyVisibility();
13309 });
13310 });
13311
13312 if (searchInput) {
13313 searchInput.addEventListener("input", function () {
13314 searchTerm = searchInput.value.trim().toLowerCase();
13315 applyVisibility();
13316 });
13317 }
13318
13319 updateLanguageButtons();
13320 sortSiblingRows();
13321 applyVisibility();
13322 }
13323
13324 function loadPreview() {
13325 if (!previewPanel || !pathInput) return;
13326 if (GIT_MODE) {
13327 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>';
13328 return;
13329 }
13330 var path = pathInput.value.trim();
13331 var zeroWarn = document.getElementById('zero-files-warning');
13332 if (!path) {
13333 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13334 if (zeroWarn) zeroWarn.style.display = 'none';
13335 return;
13336 }
13337 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13338 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13339 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
13340 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
13341 var _prevMsgs = [
13342 'Scanning directory structure…',
13343 'Detecting file types…',
13344 'Applying include / exclude filters…',
13345 'Estimating file counts…',
13346 'Building scope preview…',
13347 'Almost there…'
13348 ];
13349 var _prevMsgIdx = 0;
13350 var _prevStart = Date.now();
13351 previewPanel.innerHTML =
13352 '<div class="preview-loading">' +
13353 '<div class="preview-spinner"></div>' +
13354 '<div class="preview-loading-text">' +
13355 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
13356 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
13357 '</div></div>';
13358 var _sizeTextEl = document.getElementById('project-size-text');
13359 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
13360 window._previewInterval = setInterval(function() {
13361 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
13362 var ml = document.getElementById('plm');
13363 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
13364 }, 1500);
13365 window._previewElapsedTimer = setInterval(function() {
13366 var el = document.getElementById('ple');
13367 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
13368 }, 1000);
13369 var previewUrl = "/preview?path=" + encodeURIComponent(path)
13370 + "&include_globs=" + encodeURIComponent(includeValue)
13371 + "&exclude_globs=" + encodeURIComponent(excludeValue);
13372 fetch(previewUrl)
13373 .then(function (response) { return response.text(); })
13374 .then(function (html) {
13375 clearInterval(window._previewInterval); window._previewInterval = null;
13376 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13377 previewPanel.innerHTML = html;
13378 attachPreviewInteractions();
13379 syncPythonVisibility();
13380 updateReview();
13381 setTimeout(collapseLanguagePills, 50);
13382 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13383 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13384 var sizeText = document.getElementById('project-size-text');
13385 var sizeBtn = document.getElementById('project-size-btn');
13386 // In server mode with upload sizes available, keep the compressed/original pair.
13387 if (SERVER_MODE && window._lastUploadSizes) {
13388 var us = window._lastUploadSizes;
13389 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13390 ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13391 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13392 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13393 } else if (sizeText && projectSize) {
13394 sizeText.textContent = 'Project size: ' + projectSize;
13395 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13396 } else if (sizeText) {
13397 sizeText.textContent = 'Project size: —';
13398 }
13399 if (zeroWarn) {
13400 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13401 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13402 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13403 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13404 if (supportedCount === 0 && fileCount > 0) {
13405 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).';
13406 zeroWarn.style.display = '';
13407 } else {
13408 zeroWarn.style.display = 'none';
13409 }
13410 }
13411 })
13412 .catch(function (err) {
13413 clearInterval(window._previewInterval); window._previewInterval = null;
13414 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13415 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13416 });
13417 }
13418
13419 function pickDirectory(targetInput, kind) {
13420 if (SERVER_MODE) {
13421 if (kind === 'output') {
13422 showBannerToast(
13423 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13424 false,
13425 { top: true, icon: '📁' }
13426 );
13427 return;
13428 }
13429 var inputEl = kind === 'coverage'
13430 ? document.getElementById('cov-upload-input')
13431 : document.getElementById('dir-upload-input');
13432 if (!inputEl) return;
13433 inputEl.onchange = function () {
13434 var files = inputEl.files;
13435 if (!files || files.length === 0) return;
13436 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13437 if (browseBtn) browseBtn.disabled = true;
13438
13439 function fileToBase64(file) {
13440 return new Promise(function (resolve, reject) {
13441 var reader = new FileReader();
13442 reader.onload = function () {
13443 var b64 = reader.result.split(',')[1];
13444 resolve(b64);
13445 };
13446 reader.onerror = reject;
13447 reader.readAsDataURL(file);
13448 });
13449 }
13450
13451 if (kind === 'coverage') {
13452 var f = files[0];
13453 if (previewPanel && targetInput === pathInput)
13454 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13455 fileToBase64(f).then(function (b64) {
13456 return fetch('/api/upload-file', {
13457 method: 'POST',
13458 headers: { 'Content-Type': 'application/json' },
13459 body: JSON.stringify({ filename: f.name, content: b64 })
13460 }).then(function (r) { return r.json(); });
13461 })
13462 .then(function (d) {
13463 if (d && d.tmp_path) {
13464 if (coverageInput) coverageInput.value = d.tmp_path;
13465 setCovStatus('idle');
13466 } else if (d && d.error) { showBannerToast(d.error, true); }
13467 })
13468 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13469 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13470 } else {
13471 // ── Filter to source-code files only ─────────────────────────
13472 // Binary, generated, and dependency files (node_modules, .git,
13473 // build artifacts) are skipped so they are never uploaded.
13474 var CODE_EXTS = new Set([
13475 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13476 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13477 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13478 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13479 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13480 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13481 'tf','hcl','proto','thrift','avsc','graphql','gql'
13482 ]);
13483 var codeFiles = [];
13484 for (var i = 0; i < files.length; i++) {
13485 var f = files[i];
13486 var name = f.name;
13487 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13488 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13489 codeFiles.push(f); continue;
13490 }
13491 var dot = name.lastIndexOf('.');
13492 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13493 }
13494 // Collect specific .git metadata files for server-side git detection.
13495 // These have no source extension so they are excluded by the loop above,
13496 // but the server needs them to read branch/commit/author without running git.
13497 var gitMetaFiles = [];
13498 for (var i = 0; i < files.length; i++) {
13499 var f = files[i];
13500 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13501 var gitIdx = rp.indexOf('/.git/');
13502 if (gitIdx < 0) continue;
13503 var gitRel = rp.slice(gitIdx + 1);
13504 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13505 gitRel === '.git/logs/HEAD' ||
13506 gitRel.startsWith('.git/refs/heads/') ||
13507 gitRel.startsWith('.git/refs/tags/')) {
13508 gitMetaFiles.push(f);
13509 }
13510 }
13511 var uploadFiles = codeFiles.concat(gitMetaFiles);
13512 var total = files.length;
13513 var kept = codeFiles.length;
13514 if (kept === 0) {
13515 if (previewPanel && targetInput === pathInput)
13516 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13517 if (browseBtn) browseBtn.disabled = false;
13518 inputEl.value = '';
13519 return;
13520 }
13521
13522 // ── Helper: apply upload result to UI ────────────────────────
13523 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13524 function applyUploadResult(tmpPath, sizes) {
13525 targetInput.value = tmpPath;
13526 scrollInputToEnd(targetInput);
13527 if (sizes && SERVER_MODE) {
13528 window._lastUploadSizes = sizes;
13529 // Immediately show both sizes before preview loads.
13530 var sizeText = document.getElementById('project-size-text');
13531 var sizeBtn = document.getElementById('project-size-btn');
13532 if (sizeText) {
13533 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13534 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13535 }
13536 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13537 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13538 }
13539 if (targetInput === pathInput) {
13540 updateReportTitleFromPath();
13541 autoSetOutputDir(tmpPath);
13542 fetchProjectHistory(tmpPath);
13543 loadPreview();
13544 suggestCoverageFile(tmpPath);
13545 }
13546 updateReview();
13547 if (browseBtn) browseBtn.disabled = false;
13548 inputEl.value = '';
13549 }
13550
13551 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13552 if (typeof CompressionStream !== 'undefined') {
13553 if (previewPanel && targetInput === pathInput)
13554 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13555
13556 // Build a minimal POSIX ustar tar header for a single file entry.
13557 function buildUstarHeader(filePath, fileSize) {
13558 var BLOCK = 512;
13559 var hdr = new Uint8Array(BLOCK);
13560 var enc = new TextEncoder();
13561 function wStr(off, len, s) {
13562 var b = enc.encode(s);
13563 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13564 }
13565 function wOct(off, len, val) {
13566 var s = val.toString(8);
13567 while (s.length < len - 1) s = '0' + s;
13568 wStr(off, len, s + '\0');
13569 }
13570 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13571 var name = filePath, prefix = '';
13572 if (filePath.length > 99) {
13573 var split = filePath.lastIndexOf('/', 154);
13574 if (split > 0 && filePath.length - split - 1 <= 99) {
13575 prefix = filePath.substring(0, split);
13576 name = filePath.substring(split + 1);
13577 } else { name = filePath.substring(0, 99); }
13578 }
13579 wStr(0, 100, name); // name
13580 wOct(100, 8, 0o000644); // mode
13581 wOct(108, 8, 0); // uid
13582 wOct(116, 8, 0); // gid
13583 wOct(124, 12, fileSize); // size
13584 wOct(136, 12, 0); // mtime (epoch)
13585 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13586 hdr[156] = 48; // type flag '0' = regular file
13587 wStr(157, 100, ''); // linkname
13588 wStr(257, 6, 'ustar'); // magic
13589 wStr(263, 2, '00'); // version
13590 wStr(265, 32, ''); // uname
13591 wStr(297, 32, ''); // gname
13592 wOct(329, 8, 0); // devmajor
13593 wOct(337, 8, 0); // devminor
13594 wStr(345, 155, prefix); // prefix
13595 // Compute checksum (sum of all bytes, placeholder = 32).
13596 var chk = 0;
13597 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13598 var cs = chk.toString(8);
13599 while (cs.length < 6) cs = '0' + cs;
13600 wStr(148, 8, cs + '\0 ');
13601 return hdr;
13602 }
13603
13604 // Build tar.gz one file at a time, piping through CompressionStream.
13605 // RAM usage = compressed output buffer + one file at a time.
13606 (async function () {
13607 try {
13608 var BLOCK = 512;
13609 var cs = new CompressionStream('gzip');
13610 var writer = cs.writable.getWriter();
13611 var chunks = [];
13612 var reader = cs.readable.getReader();
13613 var collecting = (async function () {
13614 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13615 })();
13616
13617 for (var i = 0; i < uploadFiles.length; i++) {
13618 var file = uploadFiles[i];
13619 var path = file.webkitRelativePath || file.name;
13620 var buf = await file.arrayBuffer();
13621 var data = new Uint8Array(buf);
13622 // Header block
13623 await writer.write(buildUstarHeader(path, data.length));
13624 // Data padded to 512-byte boundary
13625 if (data.length > 0) {
13626 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13627 var block = new Uint8Array(padded);
13628 block.set(data);
13629 await writer.write(block);
13630 }
13631 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13632 if (previewPanel && targetInput === pathInput)
13633 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13634 }
13635 }
13636 // End-of-archive: two 512-byte zero blocks
13637 await writer.write(new Uint8Array(BLOCK * 2));
13638 await writer.close();
13639 await collecting;
13640
13641 var blob = new Blob(chunks, { type: 'application/gzip' });
13642 var sizeMB = (blob.size / 1048576).toFixed(1);
13643 if (previewPanel && targetInput === pathInput)
13644 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13645
13646 var resp = await fetch('/api/upload-tarball', {
13647 method: 'POST',
13648 headers: { 'Content-Type': 'application/gzip' },
13649 body: blob
13650 });
13651 var d = await resp.json();
13652 if (d && d.tmp_path) {
13653 applyUploadResult(d.tmp_path, {
13654 compressed_bytes: d.compressed_bytes || 0,
13655 original_bytes: d.original_bytes || 0
13656 });
13657 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13658 } catch (e) {
13659 showBannerToast('Upload failed: ' + String(e), true);
13660 if (browseBtn) browseBtn.disabled = false;
13661 inputEl.value = '';
13662 }
13663 })();
13664
13665 } else {
13666 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13667 // Used only on browsers that lack CompressionStream (pre-2023).
13668 var BATCH = 200;
13669 var batches = [];
13670 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13671 var totalBatches = batches.length;
13672 if (previewPanel && targetInput === pathInput)
13673 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13674
13675 function sendBatch(idx, currentUploadId, lastTmpPath) {
13676 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13677 if (previewPanel && targetInput === pathInput && totalBatches > 1)
13678 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13679 Promise.all(batches[idx].map(function (file) {
13680 return fileToBase64(file).then(function (b64) {
13681 return { path: file.webkitRelativePath || file.name, content: b64 };
13682 });
13683 })).then(function (fileList) {
13684 var body = { files: fileList };
13685 if (currentUploadId) body.upload_id = currentUploadId;
13686 return fetch('/api/upload-directory', {
13687 method: 'POST', headers: { 'Content-Type': 'application/json' },
13688 body: JSON.stringify(body)
13689 }).then(function (r) { return r.json(); });
13690 }).then(function (d) {
13691 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13692 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13693 }).catch(function (e) {
13694 showBannerToast('Upload failed: ' + String(e), true);
13695 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13696 });
13697 }
13698 sendBatch(0, null, '');
13699 }
13700 }
13701 };
13702 inputEl.click();
13703 return;
13704 }
13705
13706 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13707 if (browseButton) browseButton.disabled = true;
13708
13709 if (previewPanel && targetInput === pathInput) {
13710 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13711 }
13712
13713 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
13714 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13715 .then(function (data) {
13716 if (data && data.selected_path) {
13717 targetInput.value = data.selected_path;
13718 scrollInputToEnd(targetInput);
13719
13720 if (targetInput === pathInput) {
13721 updateReportTitleFromPath();
13722 autoSetOutputDir(data.selected_path);
13723 fetchProjectHistory(data.selected_path);
13724 loadPreview();
13725 suggestCoverageFile(data.selected_path);
13726 }
13727
13728 updateReview();
13729 } else if (targetInput === pathInput) {
13730 loadPreview();
13731 }
13732 })
13733 .catch(function () {
13734 window.alert("Directory picker request failed.");
13735 if (previewPanel && targetInput === pathInput) {
13736 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13737 }
13738 })
13739 .finally(function () {
13740 if (browseButton) browseButton.disabled = false;
13741 });
13742 }
13743
13744 if (themeToggle) {
13745 themeToggle.addEventListener("click", function () {
13746 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13747 applyTheme(nextTheme);
13748 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13749 });
13750 }
13751
13752 stepButtons.forEach(function (button) {
13753 button.addEventListener("click", function () {
13754 setStep(Number(button.getAttribute("data-step-target")));
13755 });
13756 });
13757
13758 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13759 button.addEventListener("click", function () {
13760 setStep(Number(button.getAttribute("data-step-target")) || 1);
13761 });
13762 });
13763
13764 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13765 button.addEventListener("click", function () {
13766 updateReview();
13767 setStep(Number(button.getAttribute("data-next")));
13768 });
13769 });
13770
13771 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13772 button.addEventListener("click", function () {
13773 setStep(Number(button.getAttribute("data-prev")));
13774 });
13775 });
13776
13777 document.addEventListener("keydown", function (e) {
13778 var tag = (document.activeElement || {}).tagName || "";
13779 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13780 if (e.altKey || e.ctrlKey || e.metaKey) return;
13781 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13782 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13783 });
13784
13785 if (useSamplePath) {
13786 useSamplePath.addEventListener("click", function () {
13787 pathInput.value = "tests/fixtures/basic";
13788 updateReportTitleFromPath();
13789 autoSetOutputDir("tests/fixtures/basic");
13790 loadPreview();
13791 suggestCoverageFile("tests/fixtures/basic");
13792 });
13793 }
13794
13795 if (useDefaultOutput) {
13796 useDefaultOutput.addEventListener("click", function () {
13797 delete outputDirInput.dataset.userEdited;
13798 autoSetOutputDir(pathInput ? pathInput.value : "");
13799 updateReview();
13800 });
13801 }
13802
13803 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13804 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13805
13806 // ── Drag-and-drop directory upload (server mode only) ─────────────────
13807 // Dropping a folder onto the path field bypasses Chrome's
13808 // "Upload X files to this site?" confirmation dialog.
13809 async function readDirRecursively(dirEntry, basePath) {
13810 var reader = dirEntry.createReader();
13811 var all = [];
13812 for (;;) {
13813 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13814 if (!batch.length) break;
13815 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13816 }
13817 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13818 var out = [];
13819 for (var i = 0; i < all.length; i++) {
13820 var sub = all[i];
13821 if (sub.isFile) {
13822 var f = await new Promise(function(res) { sub.file(res); });
13823 out.push({ file: f, path: basePath + '/' + sub.name });
13824 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13825 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13826 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13827 }
13828 }
13829 return out;
13830 }
13831
13832 function setupPathDropZone() {
13833 if (!SERVER_MODE || !pathInput) return;
13834 var CODE_EXTS = new Set([
13835 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13836 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13837 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13838 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13839 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13840 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13841 ]);
13842 pathInput.addEventListener('dragover', function(e) {
13843 e.preventDefault();
13844 pathInput.classList.add('drag-over');
13845 });
13846 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13847 pathInput.addEventListener('drop', function(e) {
13848 e.preventDefault();
13849 pathInput.classList.remove('drag-over');
13850 var items = e.dataTransfer.items;
13851 if (!items || !items.length) return;
13852 var dirEntry = null;
13853 for (var i = 0; i < items.length; i++) {
13854 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13855 if (entry && entry.isDirectory) { dirEntry = entry; break; }
13856 }
13857 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13858 var btn = browsePath;
13859 if (btn) btn.disabled = true;
13860 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13861
13862 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13863 var total = allEntries.length;
13864 var codeEntries = allEntries.filter(function(e) {
13865 var n = e.file.name;
13866 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13867 var dot = n.lastIndexOf('.');
13868 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13869 });
13870 var kept = codeEntries.length;
13871 if (kept === 0) {
13872 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13873 if (btn) btn.disabled = false; return;
13874 }
13875
13876 function finish(tmpPath, sizes) {
13877 pathInput.value = tmpPath;
13878 scrollInputToEnd(pathInput);
13879 if (sizes) {
13880 window._lastUploadSizes = sizes;
13881 var sizeText = document.getElementById('project-size-text');
13882 var sizeBtn = document.getElementById('project-size-btn');
13883 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13884 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13885 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13886 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13887 }
13888 updateReportTitleFromPath();
13889 autoSetOutputDir(tmpPath);
13890 fetchProjectHistory(tmpPath);
13891 loadPreview();
13892 suggestCoverageFile(tmpPath);
13893 updateReview();
13894 if (btn) btn.disabled = false;
13895 }
13896
13897 if (typeof CompressionStream === 'undefined') {
13898 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13899 if (btn) btn.disabled = false; return;
13900 }
13901
13902 try {
13903 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13904 var BLOCK = 512;
13905 var cs = new CompressionStream('gzip');
13906 var wtr = cs.writable.getWriter();
13907 var chunks = [];
13908 var rdr = cs.readable.getReader();
13909 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13910
13911 function buildHdr(fp, sz) {
13912 var hdr = new Uint8Array(BLOCK);
13913 var enc = new TextEncoder();
13914 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]; }
13915 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13916 var nm = fp, pfx = '';
13917 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); } }
13918 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13919 for (var i = 148; i < 156; i++) hdr[i] = 32;
13920 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);
13921 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13922 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13923 return hdr;
13924 }
13925
13926 for (var i = 0; i < codeEntries.length; i++) {
13927 var ce = codeEntries[i];
13928 var buf = await ce.file.arrayBuffer();
13929 var data = new Uint8Array(buf);
13930 await wtr.write(buildHdr(ce.path, data.length));
13931 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13932 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13933 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13934 }
13935 await wtr.write(new Uint8Array(BLOCK * 2));
13936 await wtr.close();
13937 await collecting;
13938
13939 var blob = new Blob(chunks, { type: 'application/gzip' });
13940 var sizeMB = (blob.size / 1048576).toFixed(1);
13941 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13942 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13943 var d = await resp.json();
13944 if (d && d.tmp_path) {
13945 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13946 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13947 } catch (err) {
13948 showBannerToast('Upload failed: ' + String(err), true);
13949 if (btn) btn.disabled = false;
13950 }
13951 }).catch(function(err) {
13952 showBannerToast('Could not read folder: ' + String(err), true);
13953 if (btn) btn.disabled = false;
13954 });
13955 });
13956 }
13957 setupPathDropZone();
13958 if (browseCoverage) {
13959 browseCoverage.addEventListener("click", function () {
13960 pickDirectory(coverageInput || pathInput, "coverage");
13961 });
13962 }
13963
13964 function setCovStatus(state, opts) {
13965 if (!covScanStatus) return;
13966 opts = opts || {};
13967 covScanStatus.className = "cov-scan-status cov-scan-" + state;
13968 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13969 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>';
13970 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>';
13971 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>';
13972 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>';
13973 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13974 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13975 if (state === "scanning") {
13976 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13977 } else if (state === "found") {
13978 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13979 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13980 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13981 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13982 } else if (state === "hint") {
13983 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13984 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
13985 html += '<div class="cov-scan-sub">Generate one with:</div>';
13986 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13987 } else if (state === "none") {
13988 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13989 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
13990 }
13991 html += '</div></div>';
13992 covScanStatus.innerHTML = html;
13993 if (state === "found") {
13994 var useBtn = covScanStatus.querySelector(".cov-scan-use");
13995 if (useBtn) useBtn.addEventListener("click", function () {
13996 if (coverageInput) coverageInput.value = "";
13997 covAutoFilled = false;
13998 setCovStatus("idle");
13999 });
14000 }
14001 }
14002
14003 function suggestCoverageFile(projectPath) {
14004 if (!coverageInput || !covScanStatus) return;
14005 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14006 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
14007 clearTimeout(coverageSuggestTimer);
14008 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
14009 setCovStatus("scanning");
14010 coverageSuggestTimer = setTimeout(function () {
14011 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
14012 .then(function (r) { return r.json(); })
14013 .then(function (d) {
14014 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14015 if (!d) { setCovStatus("none"); return; }
14016 if (d.found) {
14017 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
14018 setCovStatus("found", { found: d.found, tool: d.tool });
14019 } else if (d.tool && d.hint) {
14020 setCovStatus("hint", { tool: d.tool, hint: d.hint });
14021 } else {
14022 setCovStatus("none");
14023 }
14024 })
14025 .catch(function () { setCovStatus("idle"); });
14026 }, 600);
14027 }
14028
14029 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
14030
14031 if (coverageInput) coverageInput.addEventListener("input", function () {
14032 covAutoFilled = false;
14033 if (!this.value.trim()) setCovStatus("idle");
14034 });
14035
14036 // ── Language pill overflow: collapse to "+N more" chip ─────────────
14037 function collapseLanguagePills() {
14038 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
14039 rows.forEach(function(row) {
14040 // Remove any previous overflow chip
14041 var prev = row.querySelector('.lang-overflow-chip');
14042 if (prev) prev.remove();
14043 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
14044 pills.forEach(function(p) { p.style.display = ''; });
14045 if (!pills.length) return;
14046
14047 // Measure after restoring all pills
14048 var containerRight = row.getBoundingClientRect().right;
14049 var hidden = [];
14050 for (var i = pills.length - 1; i >= 1; i--) {
14051 var rect = pills[i].getBoundingClientRect();
14052 if (rect.right > containerRight + 2) {
14053 hidden.unshift(pills[i]);
14054 pills[i].style.display = 'none';
14055 } else {
14056 break;
14057 }
14058 }
14059
14060 if (hidden.length) {
14061 var chip = document.createElement('button');
14062 chip.type = 'button';
14063 chip.className = 'language-pill lang-overflow-chip';
14064 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
14065 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
14066 row.appendChild(chip);
14067 }
14068 });
14069 }
14070
14071 // Run after preview loads (preview panel populates language pills)
14072 var _origLoadPreviewCb = window.__previewLoaded;
14073 document.addEventListener('previewLoaded', collapseLanguagePills);
14074 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
14075 setTimeout(collapseLanguagePills, 400);
14076
14077 // ── Project history & output dir auto-set ──────────────────────────
14078 var wsOutputRoot = document.getElementById("ws-output-root");
14079 var wsScanCount = document.getElementById("ws-scan-count");
14080 var wsLastScan = document.getElementById("ws-last-scan");
14081 var historyBadge = document.getElementById("path-history-badge");
14082 var historyTimer = null;
14083
14084 var wsOutputLink = document.getElementById("ws-output-link");
14085 function syncStripOutputRoot() {
14086 var val = outputDirInput ? outputDirInput.value : "";
14087 var display = val || "project/sloc";
14088 if (wsOutputRoot) wsOutputRoot.textContent = display;
14089 if (wsOutputLink) wsOutputLink.dataset.folder = val;
14090 }
14091
14092 function scrollInputToEnd(input) {
14093 if (!input) return;
14094 // Defer so the DOM has the new value before we measure scroll width.
14095 requestAnimationFrame(function () {
14096 input.scrollLeft = input.scrollWidth;
14097 input.selectionStart = input.selectionEnd = input.value.length;
14098 });
14099 }
14100
14101 function autoSetOutputDir(projectPath) {
14102 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
14103 if (GIT_MODE && GIT_OUTPUT_DIR) {
14104 outputDirInput.value = GIT_OUTPUT_DIR;
14105 scrollInputToEnd(outputDirInput);
14106 syncStripOutputRoot();
14107 updateReview();
14108 return;
14109 }
14110 if (!projectPath || !projectPath.trim()) return;
14111 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
14112 outputDirInput.value = cleaned + "/sloc";
14113 scrollInputToEnd(outputDirInput);
14114 syncStripOutputRoot();
14115 updateReview();
14116 }
14117
14118 var wsBranch = document.getElementById("ws-branch");
14119
14120 function fetchProjectHistory(projectPath) {
14121 if (!projectPath || !projectPath.trim()) {
14122 if (wsScanCount) wsScanCount.textContent = "—";
14123 if (wsLastScan) wsLastScan.textContent = "—";
14124 if (wsBranch) wsBranch.textContent = "—";
14125 if (historyBadge) historyBadge.style.display = "none";
14126 return;
14127 }
14128 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
14129 .then(function (r) { return r.ok ? r.json() : null; })
14130 .then(function (data) {
14131 if (!data) return;
14132 var countStr = data.scan_count > 0
14133 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
14134 : "never";
14135 var tsStr = data.last_scan_timestamp
14136 ? data.last_scan_timestamp.replace(" UTC","")
14137 : "—";
14138 if (wsScanCount) wsScanCount.textContent = countStr;
14139 if (wsLastScan) wsLastScan.textContent = tsStr;
14140 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
14141 if (data.scan_count > 0) {
14142 if (historyBadge) {
14143 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
14144 historyBadge.textContent = data.scan_count + " previous scan" +
14145 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
14146 "Last: " + (data.last_scan_timestamp || "—") +
14147 " — " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?Math.round(v/1e3)+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
14148 historyBadge.className = "path-history-badge found";
14149 historyBadge.style.display = "";
14150 }
14151 } else {
14152 if (historyBadge) historyBadge.style.display = "none";
14153 }
14154 })
14155 .catch(function () {});
14156 }
14157
14158 function onPathChange() {
14159 var val = pathInput ? pathInput.value : "";
14160 // Discard stale upload sizes when the user edits the path manually.
14161 window._lastUploadSizes = null;
14162 updateReportTitleFromPath();
14163 autoSetOutputDir(val);
14164 updateSidebarSummary();
14165 clearTimeout(historyTimer);
14166 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
14167 if (previewTimer) clearTimeout(previewTimer);
14168 previewTimer = setTimeout(loadPreview, 280);
14169 suggestCoverageFile(val);
14170 }
14171
14172 if (pathInput) {
14173 pathInput.addEventListener("input", onPathChange);
14174 }
14175
14176 if (outputDirInput) {
14177 outputDirInput.addEventListener("input", function () {
14178 outputDirInput.dataset.userEdited = "1";
14179 syncStripOutputRoot();
14180 updateReview();
14181 });
14182 }
14183
14184 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
14185 if (!node) return;
14186 node.addEventListener("input", function () {
14187 updateReview();
14188 if (previewTimer) clearTimeout(previewTimer);
14189 previewTimer = setTimeout(loadPreview, 280);
14190 });
14191 });
14192
14193 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
14194 var node = document.getElementById(id);
14195 if (node) node.addEventListener("change", updateReview);
14196 });
14197
14198 if (reportTitleInput) {
14199 reportTitleInput.addEventListener("input", function () {
14200 reportTitleTouched = reportTitleInput.value.trim().length > 0;
14201 updateReportTitleFromPath();
14202 updateReview();
14203 });
14204 }
14205
14206 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
14207 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
14208 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
14209 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
14210
14211 artifactCards.forEach(function (card) {
14212 card.addEventListener("click", function () {
14213 if (card.classList.contains("artifact-locked")) return;
14214 toggleArtifactCard(card);
14215 updateReview();
14216 });
14217 });
14218
14219 if (coverageInput) {
14220 coverageInput.addEventListener("input", function () {
14221 if (coverageInput.value.trim()) setCovStatus("idle");
14222 });
14223 }
14224
14225 if (form && loading && submitButton) {
14226 form.addEventListener("submit", function (e) {
14227 e.preventDefault();
14228 submitButton.disabled = true;
14229 submitButton.textContent = "Scanning...";
14230 startAsyncAnalysis(new FormData(form));
14231 });
14232 }
14233
14234 function openPath(folder) {
14235 if (!folder) return;
14236 fetch('/open-path?path=' + encodeURIComponent(folder))
14237 .then(function (r) { return r.json(); })
14238 .then(function (d) {
14239 if (d && d.server_mode_disabled)
14240 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
14241 })
14242 .catch(function () {});
14243 }
14244
14245 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14246 btn.addEventListener('click', function () {
14247 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
14248 });
14249 });
14250
14251 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
14252 if (wsOutputLink) {
14253 wsOutputLink.addEventListener('click', function () {
14254 openPath(wsOutputLink.dataset.folder || '');
14255 });
14256 }
14257
14258 loadSavedTheme();
14259 updateMixedPolicyUI();
14260 updatePythonDocstringUI();
14261 applyScanPreset();
14262 updatePresetDescriptions();
14263 applyArtifactPreset();
14264 updateReview();
14265 updateScrollProgress(); // initialise bar to 0% (step 1)
14266 window.addEventListener("scroll", updateScrollProgress, { passive: true });
14267 onPathChange(); // seed output dir, history badge, and preview from initial path
14268 loadPreview();
14269 updateStepNav(1);
14270
14271 // Restore step from URL hash on initial load (e.g., back-forward cache)
14272 (function() {
14273 var hashMatch = location.hash.match(/^#step([1-4])$/);
14274 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
14275 })();
14276
14277 (function randomizeWatermarks() {
14278 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14279 if (!wms.length) return;
14280 var placed = [];
14281 function tooClose(top, left) {
14282 for (var i = 0; i < placed.length; i++) {
14283 var dt = Math.abs(placed[i][0] - top);
14284 var dl = Math.abs(placed[i][1] - left);
14285 if (dt < 16 && dl < 12) return true;
14286 }
14287 return false;
14288 }
14289 function pick(leftBand) {
14290 for (var attempt = 0; attempt < 50; attempt++) {
14291 var top = Math.random() * 88 + 2;
14292 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14293 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14294 }
14295 var top = Math.random() * 88 + 2;
14296 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14297 placed.push([top, left]);
14298 return [top, left];
14299 }
14300 var half = Math.floor(wms.length / 2);
14301 wms.forEach(function (img, i) {
14302 var pos = pick(i < half);
14303 var size = Math.floor(Math.random() * 80 + 110);
14304 var rot = (Math.random() * 360).toFixed(1);
14305 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
14306 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;
14307 });
14308 })();
14309
14310 (function spawnCodeParticles() {
14311 var container = document.getElementById('code-particles');
14312 if (!container) return;
14313 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'];
14314 for (var i = 0; i < 38; i++) {
14315 (function(idx) {
14316 var el = document.createElement('span');
14317 el.className = 'code-particle';
14318 el.textContent = snippets[idx % snippets.length];
14319 var left = Math.random() * 94 + 2;
14320 var top = Math.random() * 88 + 6;
14321 var dur = (Math.random() * 10 + 9).toFixed(1);
14322 var delay = (Math.random() * 18).toFixed(1);
14323 var rot = (Math.random() * 26 - 13).toFixed(1);
14324 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14325 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';
14326 container.appendChild(el);
14327 })(i);
14328 }
14329 })();
14330 })();
14331 </script>
14332 <script nonce="{{ csp_nonce }}">
14333 (function () {
14334 var raw = {{ prefill_json|safe }};
14335 if (!raw || typeof raw !== 'object' || !raw.path) return;
14336 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
14337 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
14338 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
14339 setVal('path-input', raw.path || '');
14340 setVal('include-globs', raw.include_globs || '');
14341 setVal('exclude-globs', raw.exclude_globs || '');
14342 setVal('output-dir', raw.output_dir || '');
14343 setVal('report-title', raw.report_title || '');
14344 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
14345 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
14346 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
14347 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
14348 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
14349 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
14350 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
14351 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
14352 setChecked('generate-html', raw.generate_html !== false);
14353 setChecked('generate-pdf', !!raw.generate_pdf);
14354 // Trigger dynamic UI updates after pre-fill.
14355 setTimeout(function () {
14356 var pathEl = document.getElementById('path-input');
14357 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
14358 var policyEl = document.getElementById('mixed-line-policy');
14359 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
14360 }, 80);
14361 })();
14362 </script>
14363 <script nonce="{{ csp_nonce }}">
14364 (function(){
14365 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'}];
14366 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);});}
14367 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14368 function init(){
14369 var btn=document.getElementById('settings-btn');if(!btn)return;
14370 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14371 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>';
14372 document.body.appendChild(m);
14373 var g=document.getElementById('scheme-grid');
14374 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);});
14375 var cl=document.getElementById('settings-close');
14376 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);
14377 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');});
14378 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14379 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14380 }
14381 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14382 }());
14383 </script>
14384 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14385 <div class="wb-ftip-arrow"></div>
14386 <span id="wb-ftip-text"></span>
14387 </div>
14388 <script nonce="{{ csp_nonce }}">(function(){
14389 var tip=document.getElementById('wb-ftip');
14390 var txt=document.getElementById('wb-ftip-text');
14391 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14392 if(!tip||!txt)return;
14393 function pos(el){
14394 var r=el.getBoundingClientRect();
14395 tip.style.display='block';
14396 var tw=tip.offsetWidth;
14397 var lx=r.left+r.width/2-tw/2;
14398 if(lx<8)lx=8;
14399 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14400 tip.style.left=lx+'px';
14401 tip.style.top=(r.bottom+8)+'px';
14402 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';}
14403 }
14404 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14405 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14406 el.addEventListener('mouseleave',function(){tip.style.display='none';});
14407 });
14408 })();
14409 (function(){
14410 function fixArtifactHintSpacing(){
14411 var grid=document.querySelector('.artifact-grid');
14412 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14413 }
14414 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14415 }());
14416 (function(){
14417 var dot=document.getElementById('status-dot');
14418 var pingEl=document.getElementById('server-ping-ms');
14419 var tipEl=document.getElementById('server-tip-ping');
14420 var fm=document.getElementById('footer-mode');
14421 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)';}}
14422 function doPing(){
14423 var t0=performance.now();
14424 fetch('/healthz',{cache:'no-store'})
14425 .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);})
14426 .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)';}});
14427 }
14428 doPing();
14429 setInterval(doPing,5000);
14430 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');}
14431 })();
14432 </script>
14433 <footer class="site-footer">
14434 local code analysis - metrics, history and reports
14435 · <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>
14436 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14437 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14438 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14439 · <a href="/api-docs" rel="noopener">REST API</a>
14440 </footer>
14441</body>
14442</html>
14443"##,
14444 ext = "html"
14445)]
14446struct IndexTemplate {
14447 version: &'static str,
14448 prefill_json: String,
14449 csp_nonce: String,
14450 git_repo: String,
14451 git_ref: String,
14452 git_label_json: String,
14453 git_output_dir_json: String,
14454 server_mode: bool,
14455}
14456
14457#[derive(Template)]
14460#[template(
14461 source = r##"
14462<!doctype html>
14463<html lang="en">
14464<head>
14465 <meta charset="utf-8">
14466 <meta name="viewport" content="width=device-width, initial-scale=1">
14467 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14468 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14469 <style nonce="{{ csp_nonce }}">
14470 :root {
14471 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14472 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14473 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14474 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14475 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14476 }
14477 body.dark-theme {
14478 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14479 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14480 }
14481 *{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;}
14482 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14483 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14484 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14485 .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;}
14486 @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));}}
14487 .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);}
14488 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14489 .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));}
14490 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14491 .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;}
14492 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14493 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14494 @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; } }
14495 .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;}
14496 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14497 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14498 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14499 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14500 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14501 .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;}
14502 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14503 .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);}
14504 .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;}
14505 .settings-close:hover{color:var(--text);background:var(--surface-2);}
14506 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14507 .settings-modal-body{padding:14px 16px 16px;}
14508 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14509 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14510 .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;}
14511 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14512 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14513 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14514 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14515 .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;}
14516 .tz-select:focus{border-color:var(--oxide);}
14517 .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;}
14518 .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;}
14519 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14520 .hero{text-align:center;margin:0 auto 18px;}
14521 .hero-logo-wrap{display:inline-block;cursor:default;}
14522 .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;}
14523 .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;}
14524 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14525 .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;}
14526 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%);}
14527 .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;
14528 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14529 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14530 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;}
14531 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14532 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14533 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;}
14534 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14535 .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;}
14536 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14537 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14538 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14539 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14540 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14541 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14542 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14543 .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;}
14544 .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;}
14545 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14546 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14547 .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);}
14548 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14549 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14550 .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);}
14551 .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);}
14552 .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);}
14553 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14554 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14555 .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;}
14556 body.dark-theme .action-card-cta{color:var(--oxide);}
14557 .action-card.view .action-card-cta{color:var(--accent-2);}
14558 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14559 .action-card.compare .action-card-cta{color:#7c3aed;}
14560 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14561 .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);}
14562 .action-card.git-tools .action-card-cta{color:#15803d;}
14563 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14564 .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);}
14565 .action-card.trend .action-card-cta{color:#0e7490;}
14566 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14567 .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);}
14568 .action-card.automation .action-card-cta{color:#b45309;}
14569 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14570 .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);}
14571 .action-card.test-metrics .action-card-cta{color:#be185d;}
14572 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14573 .action-card:hover .action-card-cta{gap:12px;}
14574 .action-card.card-split{flex-direction:row;align-items:stretch;}
14575 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14576 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14577 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14578 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14579 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14580 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14581 .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;}
14582 .ac-badge.active{opacity:1;}
14583 .ac-badge.github{border-color:#555;color:#555;}
14584 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14585 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14586 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14587 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14588 body.dark-theme .ac-right-row{color:var(--muted);}
14589 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14590 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14591 .divider{height:1px;background:var(--line);margin:32px 0;}
14592 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14593 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14594 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14595 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14596 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14597 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14598 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14599 body.dark-theme .info-chip-val{color:var(--oxide);}
14600 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14601 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14602 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14603 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14604 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14605 border:6px solid transparent;border-top-color:var(--text);}
14606 .info-chip:hover .info-chip-tip{display:block;}
14607 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14608 .chip-slide.fading{filter:blur(5px);opacity:0;}
14609 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14610 .site-footer a{color:var(--muted);}
14611 .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;}
14612 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14613 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14614 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14615 .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;}
14616 .lan-badge.local{background:var(--oxide-2);}
14617 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14618 .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);}
14619 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14620 .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;}
14621 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14622 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14623 .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;}
14624 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14625 .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;}
14626 .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);}
14627 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14628 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14629 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14630 .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;}
14631 @media (max-height: 1100px) {
14632 .page{padding-top:10px;}
14633 .hero{margin-bottom:10px;}
14634 .hero-logo{width:54px;height:60px;}
14635 .hero-logo-shadow{width:42px;}
14636 .hero-title{font-size:28px;}
14637 .hero-subtitle{font-size:13px;}
14638 .card-sections{gap:16px;margin-bottom:10px;}
14639 .card-section-grid-2,.card-section-grid-3{gap:10px;}
14640 .action-card{padding:8px 15px 8px;}
14641 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14642 .action-card-icon svg{width:18px;height:18px;}
14643 .action-card-title{font-size:13px;}
14644 .action-card-desc{font-size:11px;margin-bottom:6px;}
14645 .action-card-cta{font-size:11px;}
14646 .ac-right-row{font-size:11px;}
14647 .divider{margin:14px 0;}
14648 .info-strip{gap:7px;margin-bottom:12px;}
14649 .info-chip{padding:7px 10px;}
14650 .info-chip-val{font-size:13px;}
14651 .info-chip-label{font-size:9px;}
14652 .site-footer{padding:8px 24px;font-size:12px;}
14653 }
14654 @media (max-height: 850px) {
14655 .page{padding-top:6px;}
14656 .hero{margin-bottom:6px;}
14657 .hero-logo{width:42px;height:46px;}
14658 .hero-title{font-size:22px;}
14659 .hero-subtitle{font-size:12px;}
14660 .card-sections{gap:10px;}
14661 .action-card-desc{margin-bottom:4px;}
14662 .divider{margin:8px 0;}
14663 .info-strip{margin-bottom:6px;}
14664 .lan-local-hint{margin-top:10px;}
14665 }
14666 </style>
14667</head>
14668<body>
14669 <div class="background-watermarks" aria-hidden="true">
14670 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14671 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14672 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14673 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14674 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14675 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14676 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14677 </div>
14678 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14679 <div class="top-nav">
14680 <div class="top-nav-inner">
14681 <a class="brand" href="/">
14682 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14683 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14684 </a>
14685 <div class="nav-right">
14686 <a class="nav-pill" href="/">Home</a>
14687 <div class="nav-dropdown">
14688 <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>
14689 <div class="nav-dropdown-menu">
14690 <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>
14691 </div>
14692 </div>
14693 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14694 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14695 <div class="nav-dropdown">
14696 <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>
14697 <div class="nav-dropdown-menu">
14698 <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>
14699 </div>
14700 </div>
14701 <div class="server-status-wrap" id="server-status-wrap">
14702 <div class="nav-pill server-online-pill" id="server-status-pill">
14703 <span class="status-dot" id="status-dot"></span>
14704 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14705 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14706 </div>
14707 <div class="server-status-tip">
14708 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14709 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14710 </div>
14711 </div>
14712 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14713 <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>
14714 </button>
14715 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14716 <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>
14717 <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>
14718 </button>
14719 </div>
14720 </div>
14721 </div>
14722
14723 <div class="page">
14724 <div class="hero">
14725 <div class="hero-logo-wrap" id="hero-logo-wrap">
14726 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14727 </div>
14728 <div class="hero-logo-shadow"></div>
14729 <div class="hero-title-wrap">
14730 <div class="hero-title-aura" aria-hidden="true"></div>
14731 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14732 </div>
14733 <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>
14734 </div>
14735
14736 <div class="card-sections">
14737
14738 <div>
14739 <div class="card-section-label">Analysis</div>
14740 <div class="card-section-grid-2">
14741 <a class="action-card scan card-split" href="/scan-setup">
14742 <div class="action-card-left">
14743 <div class="action-card-icon">
14744 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14745 </div>
14746 <div class="action-card-title">Scan Project</div>
14747 <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>
14748 <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>
14749 </div>
14750 <div class="action-card-sep"></div>
14751 <div class="action-card-right">
14752 <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>
14753 <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>
14754 <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>
14755 <div class="ac-right-stat" id="acp-scan-stat"></div>
14756 </div>
14757 </a>
14758 <a class="action-card test-metrics card-split" href="/test-metrics">
14759 <div class="action-card-left">
14760 <div class="action-card-icon">
14761 <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>
14762 </div>
14763 <div class="action-card-title">Test Metrics</div>
14764 <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>
14765 <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>
14766 </div>
14767 <div class="action-card-sep"></div>
14768 <div class="action-card-right">
14769 <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>
14770 <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>
14771 <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>
14772 <div class="ac-right-stat" id="acp-test-stat"></div>
14773 </div>
14774 </a>
14775 </div>
14776 </div>
14777
14778 <div>
14779 <div class="card-section-label">Reports & Insights</div>
14780 <div class="card-section-grid-3">
14781 <a class="action-card view" href="/view-reports">
14782 <div class="action-card-icon">
14783 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14784 </div>
14785 <div class="action-card-title">View Reports</div>
14786 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14787 <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>
14788 </a>
14789 <a class="action-card compare" href="/compare-scans">
14790 <div class="action-card-icon">
14791 <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>
14792 </div>
14793 <div class="action-card-title">Compare Scans</div>
14794 <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>
14795 <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>
14796 </a>
14797 <a class="action-card trend" href="/trend-reports">
14798 <div class="action-card-icon">
14799 <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>
14800 </div>
14801 <div class="action-card-title">Trend Report</div>
14802 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14803 <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>
14804 </a>
14805 </div>
14806 </div>
14807
14808 <div>
14809 <div class="card-section-label">Developer Tools</div>
14810 <div class="card-section-grid-2">
14811 <a class="action-card git-tools card-split" href="/git-browser">
14812 <div class="action-card-left">
14813 <div class="action-card-icon">
14814 <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>
14815 </div>
14816 <div class="action-card-title">Git Browser</div>
14817 <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>
14818 <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>
14819 </div>
14820 <div class="action-card-sep"></div>
14821 <div class="action-card-right">
14822 <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>
14823 <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>
14824 <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>
14825 </div>
14826 </a>
14827 <a class="action-card automation card-split" href="/integrations">
14828 <div class="action-card-left">
14829 <div class="action-card-icon">
14830 <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>
14831 </div>
14832 <div class="action-card-title">Integrations</div>
14833 <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>
14834 <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>
14835 </div>
14836 <div class="action-card-sep"></div>
14837 <div class="action-card-right">
14838 <div class="ac-badges-grid">
14839 <span class="ac-badge github" id="acp-gh">GitHub</span>
14840 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
14841 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
14842 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14843 </div>
14844 <div class="ac-right-stat" id="acp-int-stat"></div>
14845 </div>
14846 </a>
14847 </div>
14848 </div>
14849
14850 </div>
14851
14852 {% if server_mode %}
14853 <div class="lan-card server">
14854 <div class="lan-card-header">
14855 <span class="lan-badge">LAN server</span>
14856 Accessible on your network
14857 </div>
14858 {% if let Some(ip) = lan_ip %}
14859 <div class="lan-url-row">
14860 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14861 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14862 <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>
14863 Copy URL
14864 </button>
14865 </div>
14866 <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>
14867 {% if has_api_key %}
14868 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
14869 {% endif %}
14870 {% else %}
14871 <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>
14872 {% endif %}
14873 </div>
14874 {% endif %}
14875
14876 <div class="divider"></div>
14877
14878 <div class="info-strip">
14879 <div class="info-chip">
14880 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14881 <div class="chip-slide">
14882 <div class="info-chip-val">41</div>
14883 <div class="info-chip-label">Languages</div>
14884 </div>
14885 </div>
14886 <div class="info-chip">
14887 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14888 <div class="chip-slide">
14889 <div class="info-chip-val">100%</div>
14890 <div class="info-chip-label">Self-contained</div>
14891 </div>
14892 </div>
14893 <div class="info-chip">
14894 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14895 <div class="chip-slide">
14896 <div class="info-chip-val">HTML+PDF</div>
14897 <div class="info-chip-label">Exportable reports</div>
14898 </div>
14899 </div>
14900 <div class="info-chip">
14901 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14902 <div class="chip-slide">
14903 <div class="info-chip-val">Webhook</div>
14904 <div class="info-chip-label">3 platforms</div>
14905 </div>
14906 </div>
14907 <div class="info-chip">
14908 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14909 <div class="chip-slide">
14910 <div class="info-chip-val">IEEE</div>
14911 <div class="info-chip-label">1045-1992</div>
14912 </div>
14913 </div>
14914 </div>
14915
14916 {% if lan_ip.is_none() %}
14917 <div class="lan-local-hint">
14918 <strong>Want teammates on the same network to access this?</strong><br>
14919 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
14920 </div>
14921 {% endif %}
14922 </div>
14923
14924 <footer class="site-footer">
14925 local code analysis - metrics, history and reports
14926 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
14927 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14928 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14929 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14930 · <a href="/api-docs" rel="noopener">REST API</a>
14931 </footer>
14932
14933 <script nonce="{{ csp_nonce }}">
14934 (function () {
14935 var storageKey = 'oxide-sloc-theme';
14936 var body = document.body;
14937 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14938 var toggle = document.getElementById('theme-toggle');
14939 if (toggle) toggle.addEventListener('click', function () {
14940 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14941 body.classList.toggle('dark-theme', next === 'dark');
14942 try { localStorage.setItem(storageKey, next); } catch(e) {}
14943 });
14944 var copyBtn = document.getElementById('lan-copy-btn');
14945 if (copyBtn) copyBtn.addEventListener('click', function() {
14946 var btn = this;
14947 var el = document.getElementById('lan-url-val');
14948 if (!el) return;
14949 var url = el.textContent.trim();
14950 if (navigator.clipboard) {
14951 navigator.clipboard.writeText(url).then(function() {
14952 var orig = btn.innerHTML;
14953 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!';
14954 setTimeout(function() { btn.innerHTML = orig; }, 1800);
14955 });
14956 }
14957 });
14958 (function randomizeWatermarks() {
14959 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14960 if (!wms.length) return;
14961 var placed = [];
14962 function tooClose(top, left) {
14963 for (var i = 0; i < placed.length; i++) {
14964 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14965 if (dt < 16 && dl < 12) return true;
14966 }
14967 return false;
14968 }
14969 function pick(leftBand) {
14970 for (var attempt = 0; attempt < 50; attempt++) {
14971 var top = Math.random() * 88 + 2;
14972 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14973 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14974 }
14975 var top = Math.random() * 88 + 2;
14976 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14977 placed.push([top, left]); return [top, left];
14978 }
14979 var half = Math.floor(wms.length / 2);
14980 wms.forEach(function (img, i) {
14981 var pos = pick(i < half);
14982 var size = Math.floor(Math.random() * 100 + 120);
14983 var rot = (Math.random() * 360).toFixed(1);
14984 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14985 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;
14986 });
14987 })();
14988
14989 (function spawnCodeParticles() {
14990 var container = document.getElementById('code-particles');
14991 if (!container) return;
14992 var snippets = [
14993 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14994 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14995 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14996 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14997 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14998 ];
14999 var count = 38;
15000 for (var i = 0; i < count; i++) {
15001 (function(idx) {
15002 var el = document.createElement('span');
15003 el.className = 'code-particle';
15004 var text = snippets[idx % snippets.length];
15005 el.textContent = text;
15006 var left = Math.random() * 94 + 2;
15007 var top = Math.random() * 88 + 6;
15008 var dur = (Math.random() * 10 + 9).toFixed(1);
15009 var delay = (Math.random() * 18).toFixed(1);
15010 var rot = (Math.random() * 26 - 13).toFixed(1);
15011 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15012 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
15013 + '--rot:' + rot + 'deg;--op:' + op + ';'
15014 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
15015 container.appendChild(el);
15016 })(i);
15017 }
15018 })();
15019 (function heroAnimations() {
15020 var sub = document.getElementById('hero-subtitle');
15021 if (sub) {
15022 var full = sub.textContent.trim();
15023 sub.textContent = '';
15024 sub.style.opacity = '1';
15025 var cursor = document.createElement('span');
15026 cursor.className = 'hero-cursor';
15027 sub.appendChild(cursor);
15028 var i = 0;
15029 setTimeout(function() {
15030 var iv = setInterval(function() {
15031 if (i < full.length) {
15032 sub.insertBefore(document.createTextNode(full[i]), cursor);
15033 i++;
15034 } else {
15035 clearInterval(iv);
15036 setTimeout(function() {
15037 cursor.style.transition = 'opacity 1s ease';
15038 cursor.style.opacity = '0';
15039 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
15040 }, 2400);
15041 }
15042 }, 11);
15043 }, 374);
15044 }
15045 })();
15046 (function logoBob() {
15047 var logo = document.querySelector('.hero-logo');
15048 var shadow = document.querySelector('.hero-logo-shadow');
15049 if (!logo) return;
15050 var cycleStart = null, cycleDur = 3600;
15051 var peakY = -14, peakScale = 1.07, peakRot = 0;
15052 function newCycle() {
15053 cycleDur = 3000 + Math.random() * 1840;
15054 peakY = -(9 + Math.random() * 13.8);
15055 peakScale = 1.04 + Math.random() * 0.081;
15056 peakRot = (Math.random() * 11.5 - 5.75);
15057 }
15058 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
15059 newCycle();
15060 function frame(ts) {
15061 if (cycleStart === null) cycleStart = ts;
15062 var t = (ts - cycleStart) / cycleDur;
15063 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
15064 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
15065 var y = peakY * phase;
15066 var sc = 1 + (peakScale - 1) * phase;
15067 var rot = peakRot * Math.sin(Math.PI * phase);
15068 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
15069 if (shadow) {
15070 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
15071 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
15072 }
15073 requestAnimationFrame(frame);
15074 }
15075 requestAnimationFrame(frame);
15076 })();
15077 (function mouseEffects() {
15078 var heroTitle = document.getElementById('hero-title');
15079 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
15080 function tick() {
15081 raf = null;
15082 if (heroTitle) {
15083 var r = heroTitle.getBoundingClientRect();
15084 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
15085 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
15086 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
15087 }
15088 }
15089 document.addEventListener('mousemove', function(e) {
15090 mx = e.clientX; my = e.clientY;
15091 if (!raf) raf = requestAnimationFrame(tick);
15092 });
15093 document.addEventListener('mouseleave', function() {
15094 if (heroTitle) {
15095 heroTitle.style.transition = 'transform 0.5s ease';
15096 heroTitle.style.transform = '';
15097 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
15098 }
15099 });
15100 document.querySelectorAll('.action-card').forEach(function(card) {
15101 card.addEventListener('mousemove', function(e) {
15102 var rect = card.getBoundingClientRect();
15103 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
15104 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
15105 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
15106 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
15107 });
15108 card.addEventListener('mouseleave', function() {
15109 card.style.transition = '';
15110 card.style.transform = '';
15111 });
15112 });
15113 })();
15114 (function chipSlideshow() {
15115 var slides = [
15116 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
15117 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
15118 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
15119 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
15120 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
15121 ];
15122 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
15123 var indices = [0,0,0,0,0];
15124 var paused = [false,false,false,false,false];
15125 chips.forEach(function(chip, i) {
15126 chip.addEventListener('mouseenter', function() { paused[i] = true; });
15127 chip.addEventListener('mouseleave', function() { paused[i] = false; });
15128 });
15129 function advance(i) {
15130 if (paused[i]) return;
15131 var chip = chips[i];
15132 var inner = chip.querySelector('.chip-slide');
15133 if (!inner) return;
15134 inner.classList.add('fading');
15135 setTimeout(function() {
15136 indices[i] = (indices[i] + 1) % slides[i].length;
15137 var s = slides[i][indices[i]];
15138 chip.querySelector('.info-chip-val').textContent = s.v;
15139 chip.querySelector('.info-chip-label').textContent = s.l;
15140 inner.classList.remove('fading');
15141 }, 720);
15142 }
15143 setInterval(function() {
15144 chips.forEach(function(chip, i) { advance(i); });
15145 }, 6000);
15146 })();
15147 (function cardLiveData() {
15148 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
15149 var el = document.getElementById('acp-scan-stat');
15150 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
15151 }).catch(function(){});
15152 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
15153 var el = document.getElementById('acp-test-stat');
15154 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
15155 }).catch(function(){});
15156 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
15157 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
15158 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
15159 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
15160 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
15161 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
15162 var stat = document.getElementById('acp-int-stat');
15163 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
15164 }).catch(function(){});
15165 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
15166 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
15167 }).catch(function(){});
15168 })();
15169 })();
15170 </script>
15171 <script nonce="{{ csp_nonce }}">
15172 (function(){
15173 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'}];
15174 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);});}
15175 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15176 function init(){
15177 var btn=document.getElementById('settings-btn');if(!btn)return;
15178 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15179 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>';
15180 document.body.appendChild(m);
15181 var g=document.getElementById('scheme-grid');
15182 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);});
15183 var cl=document.getElementById('settings-close');
15184 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);
15185 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');});
15186 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15187 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15188 }
15189 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15190 }());
15191 </script>
15192 <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>
15193</body>
15194</html>
15195"##,
15196 ext = "html"
15197)]
15198struct SplashTemplate {
15199 csp_nonce: String,
15200 server_mode: bool,
15201 lan_ip: Option<String>,
15202 port: u16,
15203 version: &'static str,
15204 has_api_key: bool,
15205}
15206
15207#[derive(Template)]
15210#[template(
15211 source = r##"
15212<!doctype html>
15213<html lang="en">
15214<head>
15215 <meta charset="utf-8">
15216 <meta name="viewport" content="width=device-width, initial-scale=1">
15217 <title>OxideSLOC — Start a Scan</title>
15218 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15219 <style nonce="{{ csp_nonce }}">
15220 :root {
15221 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
15222 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15223 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15224 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15225 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
15226 }
15227 body.dark-theme {
15228 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
15229 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
15230 }
15231 *{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;}
15232 .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);}
15233 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15234 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
15235 .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));}
15236 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
15237 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
15238 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
15239 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15240 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15241 @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; } }
15242 .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;}
15243 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15244 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
15245 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15246 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15247 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15248 .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;}
15249 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15250 .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);}
15251 .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;}
15252 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15253 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15254 .settings-modal-body{padding:14px 16px 16px;}
15255 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15256 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15257 .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;}
15258 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15259 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15260 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15261 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15262 .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;}
15263 .tz-select:focus{border-color:var(--oxide);}
15264 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
15265 .page-header{text-align:center;margin-bottom:16px;}
15266 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
15267 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
15268 /* Cards */
15269 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
15270 .option-card-wrap{position:relative;}
15271 .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;}
15272 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
15273 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
15274 .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;}
15275 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
15276 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
15277 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
15278 .card-top-row{display:flex;align-items:center;gap:20px;}
15279 /* Two-column layout inside each card */
15280 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
15281 .card-left{display:flex;align-items:flex-start;min-width:0;}
15282 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
15283 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
15284 .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);}
15285 .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);}
15286 .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);}
15287 .card-text{min-width:0;}
15288 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
15289 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
15290 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
15291 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
15292 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
15293 /* Right CTA column */
15294 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
15295 .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;}
15296 /* Re-scan count badge */
15297 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
15298 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
15299 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
15300 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
15301 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
15302 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
15303 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
15304 body.dark-theme .btn-secondary{color:var(--oxide);}
15305 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
15306 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
15307 /* File input overlay — must be full-width so it aligns with other card-right buttons */
15308 .file-input-wrap{position:relative;width:100%;}
15309 .file-input-wrap .btn{width:100%;}
15310 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
15311 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15312 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15313 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15314 .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;}
15315 @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));}}
15316 /* Recent list (card 3 — full-width section below header) */
15317 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
15318 .recent-list{display:flex;flex-direction:column;gap:8px;}
15319 .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;}
15320 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
15321 .recent-item-info{flex:1;min-width:0;}
15322 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
15323 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
15324 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
15325 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
15326 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15327 .site-footer a{color:var(--muted);}
15328 @media(max-width:680px){
15329 .card-body{grid-template-columns:1fr;}
15330 .card-right{flex-direction:row;flex-wrap:wrap;}
15331 .btn{flex:1;}
15332 }
15333 .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;}
15334 .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;}
15335 .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;}
15336 </style>
15337</head>
15338<body>
15339 <div class="background-watermarks" aria-hidden="true">
15340 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15341 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15342 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15343 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15344 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15345 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15346 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15347 </div>
15348 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15349 <div class="top-nav">
15350 <div class="top-nav-inner">
15351 <a class="brand" href="/">
15352 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15353 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15354 </a>
15355 <div class="nav-right">
15356 <a class="nav-pill" href="/">Home</a>
15357 <div class="nav-dropdown">
15358 <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>
15359 <div class="nav-dropdown-menu">
15360 <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>
15361 </div>
15362 </div>
15363 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15364 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15365 <div class="nav-dropdown">
15366 <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>
15367 <div class="nav-dropdown-menu">
15368 <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>
15369 </div>
15370 </div>
15371 <div class="server-status-wrap" id="server-status-wrap">
15372 <div class="nav-pill server-online-pill" id="server-status-pill">
15373 <span class="status-dot" id="status-dot"></span>
15374 <span id="server-status-label">Server</span>
15375 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15376 </div>
15377 <div class="server-status-tip">
15378 OxideSLOC is running — accessible on your network.
15379 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15380 </div>
15381 </div>
15382 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15383 <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>
15384 </button>
15385 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15386 <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>
15387 <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>
15388 </button>
15389 </div>
15390 </div>
15391 </div>
15392
15393 <div class="page">
15394 <div class="page-header">
15395 <h1>How would you like to scan?</h1>
15396 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15397 </div>
15398
15399 <div class="option-grid">
15400
15401 <!-- Option 1: New scan -->
15402 <div class="option-card-wrap">
15403 <div class="option-card">
15404 <div class="option-icon new-scan">
15405 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15406 </div>
15407 <div class="card-body">
15408 <div class="card-left">
15409 <div class="card-text">
15410 <div class="option-title">Start a new scan</div>
15411 <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>
15412 <ul class="feature-list">
15413 <li>Live project scope preview before you run</li>
15414 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15415 <li>HTML, PDF, and JSON output — your choice</li>
15416 </ul>
15417 </div>
15418 </div>
15419 <div class="card-right">
15420 <a class="btn btn-primary" href="/scan">
15421 Configure & scan
15422 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15423 </a>
15424 <p class="card-tip">Full 4-step setup · all options</p>
15425 </div>
15426 </div>
15427 </div>
15428 </div>
15429
15430 <!-- Option 2: Load from config file -->
15431 <div class="option-card-wrap">
15432 <div class="option-card">
15433 <div class="option-icon load-config">
15434 <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>
15435 </div>
15436 <div class="card-body">
15437 <div class="card-left">
15438 <div class="card-text">
15439 <div class="option-title">Load a saved config</div>
15440 <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>
15441 <ul class="feature-list">
15442 <li>All 15 settings restored from the file</li>
15443 <li>Fully editable — change path or output dir</li>
15444 <li>Works with any scan-config.json</li>
15445 </ul>
15446 </div>
15447 </div>
15448 <div class="card-right">
15449 <div class="file-input-wrap">
15450 <button class="btn btn-secondary" id="load-config-btn" type="button">
15451 <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>
15452 Choose config file
15453 </button>
15454 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15455 </div>
15456 <p class="card-tip" id="config-file-name">Exported after every scan</p>
15457 </div>
15458 </div>
15459 </div>
15460 </div>
15461
15462 <!-- Option 3: Re-scan recent project -->
15463 <div class="option-card-wrap">
15464 <div class="option-card" id="recent-card">
15465 <div class="card-top-row">
15466 <div class="option-icon rescan">
15467 <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>
15468 </div>
15469 <div class="card-body">
15470 <div class="card-left">
15471 <div class="card-text">
15472 <div class="option-title">Re-scan a recent project</div>
15473 <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>
15474 <ul class="feature-list">
15475 <li>All 15+ settings restored from the saved config</li>
15476 <li>Path and output dir are editable before running</li>
15477 <li>Only scans with a saved config appear here</li>
15478 </ul>
15479 </div>
15480 </div>
15481 <div class="card-right">
15482 <div class="rescan-count-box">
15483 <div class="rescan-count-num" id="rescan-count-num">—</div>
15484 <div class="rescan-count-label">saved configs</div>
15485 </div>
15486 <a class="btn btn-secondary" href="/view-reports">
15487 <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>
15488 View all runs
15489 </a>
15490 <p class="card-tip">Opens run history</p>
15491 </div>
15492 </div>
15493 </div>
15494 <div class="section-divider"></div>
15495 <div class="recent-list" id="recent-list">
15496 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15497 </div>
15498 </div>
15499 </div>
15500
15501 </div>
15502 </div>
15503
15504 <footer class="site-footer">
15505 local code analysis - metrics, history and reports
15506 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
15507 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15508 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15509 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15510 · <a href="/api-docs" rel="noopener">REST API</a>
15511 </footer>
15512
15513 <script nonce="{{ csp_nonce }}">
15514 (function () {
15515 var storageKey = 'oxide-sloc-theme';
15516 var body = document.body;
15517 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15518 var toggle = document.getElementById('theme-toggle');
15519 if (toggle) toggle.addEventListener('click', function () {
15520 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15521 body.classList.toggle('dark-theme', next === 'dark');
15522 try { localStorage.setItem(storageKey, next); } catch(e) {}
15523 });
15524
15525 (function randomizeWatermarks() {
15526 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15527 if (!wms.length) return;
15528 var placed = [];
15529 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; }
15530 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]; }
15531 var half = Math.floor(wms.length / 2);
15532 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; });
15533 })();
15534 (function spawnCodeParticles() {
15535 var container = document.getElementById('code-particles');
15536 if (!container) return;
15537 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'];
15538 var count = 38;
15539 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); }
15540 })();
15541 // Recent scans data injected from server
15542 var recentScans = {{ recent_scans_json|safe }};
15543
15544 function configToParams(cfg) {
15545 var p = new URLSearchParams();
15546 p.set('prefilled', '1');
15547 if (cfg.path) p.set('path', cfg.path);
15548 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15549 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15550 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15551 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15552 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15553 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15554 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15555 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15556 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15557 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15558 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15559 if (cfg.report_title) p.set('report_title', cfg.report_title);
15560 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15561 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15562 return p;
15563 }
15564
15565 // Build recent scan list (capped at 3 visible entries)
15566 var list = document.getElementById('recent-list');
15567 var noNote = document.getElementById('no-recent-note');
15568 var hasAny = false;
15569 var MAX_RECENT = 3;
15570 if (Array.isArray(recentScans)) {
15571 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15572 var shown = 0;
15573 validEntries.forEach(function (entry) {
15574 if (shown >= MAX_RECENT) return;
15575 shown++;
15576 hasAny = true;
15577 var item = document.createElement('div');
15578 item.className = 'recent-item';
15579 item.title = 'Restore all settings and open wizard';
15580 item.innerHTML =
15581 '<div class="recent-item-info">' +
15582 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15583 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
15584 '</div>' +
15585 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15586 item.addEventListener('click', function () {
15587 var params = configToParams(entry.config);
15588 window.location.href = '/scan?' + params.toString();
15589 });
15590 list.appendChild(item);
15591 });
15592 if (validEntries.length > MAX_RECENT) {
15593 var moreEl = document.createElement('div');
15594 moreEl.className = 'recent-more-link';
15595 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
15596 list.appendChild(moreEl);
15597 }
15598 }
15599 if (hasAny && noNote) noNote.style.display = 'none';
15600 // Update count badge
15601 var countEl = document.getElementById('rescan-count-num');
15602 if (countEl) {
15603 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15604 countEl.textContent = total > 0 ? total : '0';
15605 }
15606
15607 // Config file loader
15608 var fileInput = document.getElementById('config-file-input');
15609 var fileName = document.getElementById('config-file-name');
15610 if (fileInput) {
15611 fileInput.addEventListener('change', function () {
15612 var file = fileInput.files && fileInput.files[0];
15613 if (!file) return;
15614 if (fileName) fileName.textContent = '✓ ' + file.name;
15615 var reader = new FileReader();
15616 reader.onload = function (e) {
15617 try {
15618 var cfg = JSON.parse(e.target.result);
15619 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15620 var params = configToParams(cfg);
15621 window.location.href = '/scan?' + params.toString();
15622 } catch (err) {
15623 alert('Could not parse config file: ' + err.message);
15624 }
15625 };
15626 reader.readAsText(file);
15627 });
15628 }
15629
15630 function escHtml(s) {
15631 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
15632 }
15633 })();
15634 </script>
15635 <script nonce="{{ csp_nonce }}">
15636 (function(){
15637 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'}];
15638 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);});}
15639 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15640 function init(){
15641 var btn=document.getElementById('settings-btn');if(!btn)return;
15642 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15643 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>';
15644 document.body.appendChild(m);
15645 var g=document.getElementById('scheme-grid');
15646 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);});
15647 var cl=document.getElementById('settings-close');
15648 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);
15649 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');});
15650 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15651 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15652 }
15653 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15654 }());
15655 </script>
15656 <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>
15657</body>
15658</html>
15659"##,
15660 ext = "html"
15661)]
15662struct ScanSetupTemplate {
15663 version: &'static str,
15664 recent_scans_json: String,
15665 csp_nonce: String,
15666}
15667
15668#[derive(Template)]
15669#[template(
15670 source = r##"
15671<!doctype html>
15672<html lang="en">
15673<head>
15674 <meta charset="utf-8">
15675 <meta name="viewport" content="width=device-width, initial-scale=1">
15676 <title>OxideSLOC | {{ report_title }} | Report</title>
15677 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15678 <style nonce="{{ csp_nonce }}">
15679 :root {
15680 --radius: 18px;
15681 --bg: #f5efe8;
15682 --surface: rgba(255,255,255,0.82);
15683 --surface-2: #fbf7f2;
15684 --surface-3: #efe6dc;
15685 --line: #e6d0bf;
15686 --line-strong: #dcb89f;
15687 --text: #43342d;
15688 --muted: #7b675b;
15689 --muted-2: #a08777;
15690 --nav: #b85d33;
15691 --nav-2: #7a371b;
15692 --accent: #6f9bff;
15693 --accent-2: #4a78ee;
15694 --oxide: #d37a4c;
15695 --oxide-2: #b35428;
15696 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15697 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15698 --success-bg: #e8f5ed;
15699 --success-text: #1a8f47;
15700 --info-bg: #eef3ff;
15701 --info-text: #4467d8;
15702 }
15703
15704 body.dark-theme {
15705 --bg: #1b1511;
15706 --surface: #261c17;
15707 --surface-2: #2d221d;
15708 --surface-3: #372922;
15709 --line: #524238;
15710 --line-strong: #6c5649;
15711 --text: #f5ece6;
15712 --muted: #c7b7aa;
15713 --muted-2: #aa9485;
15714 --nav: #b85d33;
15715 --nav-2: #7a371b;
15716 --accent: #6f9bff;
15717 --accent-2: #4a78ee;
15718 --oxide: #d37a4c;
15719 --oxide-2: #b35428;
15720 --shadow: 0 18px 42px rgba(0,0,0,0.28);
15721 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15722 --success-bg: #163927;
15723 --success-text: #8fe2a8;
15724 --info-bg: #1c2847;
15725 --info-text: #a9c1ff;
15726 }
15727
15728 * { box-sizing: border-box; }
15729 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); }
15730 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15731 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15732 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15733 .top-nav, .page { position: relative; z-index: 2; }
15734 .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); }
15735 .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; }
15736 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15737 .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)); }
15738 .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; }
15739 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15740 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15741 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15742 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15743 .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; }
15744 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15745 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15746 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15747 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15748 @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; } }
15749 .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; }
15750 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15751 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15752 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15753 .theme-toggle .icon-sun { display:none; }
15754 body.dark-theme .theme-toggle .icon-sun { display:block; }
15755 body.dark-theme .theme-toggle .icon-moon { display:none; }
15756 .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;}
15757 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15758 .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);}
15759 .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;}
15760 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15761 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15762 .settings-modal-body{padding:14px 16px 16px;}
15763 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15764 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15765 .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;}
15766 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15767 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15768 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15769 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15770 .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;}
15771 .tz-select:focus{border-color:var(--oxide);}
15772 .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; }
15773 .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;}
15774 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
15775 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15776 .hero, .panel { padding: 22px; }
15777 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15778 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15779 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15780 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15781 .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; }
15782 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15783 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15784 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15785 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15786 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15787 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15788 .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; }
15789 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15790 .delta-card-val { font-size:16px; font-weight:800; }
15791 .delta-card-val.pos { color:#1e7e34; }
15792 .delta-card-val.neg { color:var(--neg); }
15793 .delta-card-val.mod { color:#b35428; }
15794 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15795 .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; }
15796 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15797 .delta-card-inline:hover .delta-card-tip { opacity:1; }
15798 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15799 .compare-ts { font-size:13px; color:var(--muted); }
15800 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15801 .compare-arrow { color: var(--muted); }
15802 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15803 .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; }
15804 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15805 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15806 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15807 .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; }
15808 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15809 .run-mgmt-card .action-buttons { justify-content:center; }
15810 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
15811 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15812 .button, .copy-button {
15813 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;
15814 }
15815 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15816 @keyframes spin { to { transform: rotate(360deg); } }
15817 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15818 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15819 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15820 .path-item strong { display: block; margin-bottom: 6px; }
15821 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15822 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15823 .path-subitem { flex: 1; }
15824 .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); }
15825 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); }
15826 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15827 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15828 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15829 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15830 th { color: var(--muted); font-weight: 700; }
15831 tr:last-child td { border-bottom: none; }
15832 #subm-tbl col:nth-child(1){width:15%;}
15833 #subm-tbl col:nth-child(2){width:31%;}
15834 #subm-tbl col:nth-child(3){width:9%;}
15835 #subm-tbl col:nth-child(4){width:9%;}
15836 #subm-tbl col:nth-child(5){width:9%;}
15837 #subm-tbl col:nth-child(6){width:9%;}
15838 #subm-tbl col:nth-child(7){width:9%;}
15839 #subm-tbl col:nth-child(8){width:9%;}
15840 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15841 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15842 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15843 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15844 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15845 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15846 .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; }
15847 .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; }
15848 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
15849 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
15850 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15851 .muted { color: var(--muted); }
15852 /* Run-ID chip row (mirrors HTML report) */
15853 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
15854 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
15855 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
15856 .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; }
15857 .run-id-chip[data-copy] { cursor:pointer; }
15858 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15859 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15860 .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; }
15861 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15862 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15863 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
15864 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15865 a.commit-link-value { color:inherit; text-decoration:none; }
15866 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
15867 .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; }
15868 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15869 .run-id-chip:hover .chip-tooltip { opacity:1; }
15870 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15871 .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; }
15872 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15873 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15874 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15875 /* Meta chips row */
15876 .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%; }
15877 .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; }
15878 .meta-chip:last-child { border-right:none; }
15879 .meta-chip b { color:var(--text); font-weight:700; }
15880 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15881 .site-footer a{color:var(--muted);}
15882 .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; }
15883 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15884 .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; }
15885 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15886 /* Stat chips (matches HTML report) */
15887 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15888 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15889 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15890 .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; }
15891 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15892 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15893 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15894 .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; }
15895 .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; }
15896 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15897 .stat-chip:hover .stat-chip-tip { opacity:1; }
15898 /* Submodule panel */
15899 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15900 /* Metrics tables stack */
15901 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15902 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15903 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15904 .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)); }
15905 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15906 /* Metrics table */
15907 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15908 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15909 .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; }
15910 .metrics-table thead th:not(:first-child) { text-align: right; }
15911 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15912 .metrics-table tbody tr:last-child td { border-bottom: none; }
15913 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15914 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15915 .metrics-table tbody tr:hover td { background: var(--surface-2); }
15916 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15917 .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; }
15918 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15919 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15920 .mt-val-pos { color: var(--pos); font-weight: 700; }
15921 .mt-val-neg { color: var(--neg); font-weight: 700; }
15922 .mt-val-zero { color: var(--muted); }
15923 .mt-val-mod { color: var(--oxide-2); }
15924 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15925 @media (max-width: 1180px) {
15926 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15927 .nav-project-slot, .nav-status { justify-content:flex-start; }
15928 .hero-top { flex-direction: column; }
15929 .run-mgmt-strip { flex-direction: column; }
15930 }
15931 .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;}
15932 @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));}}
15933 .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;}
15934 /* ── Result-page chart controls ─────────────────────────────────────────── */
15935 .r-chart-section{margin-bottom:24px;}
15936 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15937 .section-pair > .panel{flex-shrink:0;}
15938 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15939 .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;}
15940 .r-chart-select:focus{border-color:var(--accent);}
15941 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15942 .r-chart-container svg{display:block;width:100%;height:auto;}
15943 .r-expand-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;color:var(--muted);padding:3px 8px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;}
15944 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15945 .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;}
15946 .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);}
15947 .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;}
15948 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}
15949 .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;}
15950 .r-chart-modal-close:hover{opacity:.7;}
15951 body.dark-theme .r-chart-modal{background:var(--surface);}
15952 .r-chart-container .rchit,.r-expand-modal-chart .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15953 .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover{opacity:.75;filter:brightness(1.14);}
15954 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15955 .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;}
15956 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15957 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15958 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15959 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15960 #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;}
15961 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15962 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15963 .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;}
15964 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15965 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15966 .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;}
15967 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15968 .report-id-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;text-align:center;height:27px;line-height:27px;padding:0 16px;position:fixed;top:0;left:0;right:0;z-index:32;width:100%;}
15969 .report-id-footer-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;text-align:center;height:27px;line-height:27px;padding:0 16px;position:fixed;bottom:0;left:0;right:0;z-index:32;width:100%;}
15970 body.has-report-banner .top-nav{top:27px;}
15971 body.has-report-banner{padding-bottom:27px;}
15972 </style>
15973</head>
15974<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15975 <div class="background-watermarks" aria-hidden="true">
15976 <img src="/images/logo/logo-text.png" alt="" />
15977 <img src="/images/logo/logo-text.png" alt="" />
15978 <img src="/images/logo/logo-text.png" alt="" />
15979 <img src="/images/logo/logo-text.png" alt="" />
15980 <img src="/images/logo/logo-text.png" alt="" />
15981 <img src="/images/logo/logo-text.png" alt="" />
15982 <img src="/images/logo/logo-text.png" alt="" />
15983 <img src="/images/logo/logo-text.png" alt="" />
15984 <img src="/images/logo/logo-text.png" alt="" />
15985 <img src="/images/logo/logo-text.png" alt="" />
15986 <img src="/images/logo/logo-text.png" alt="" />
15987 <img src="/images/logo/logo-text.png" alt="" />
15988 <img src="/images/logo/logo-text.png" alt="" />
15989 <img src="/images/logo/logo-text.png" alt="" />
15990 </div>
15991 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15992 {% if let Some(banner) = report_header_footer %}
15993 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15994 {% endif %}
15995 <div class="top-nav">
15996 <div class="top-nav-inner">
15997 <a class="brand" href="/">
15998 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15999 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16000 </a>
16001 <div class="nav-project-slot">
16002 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
16003 </div>
16004 <div class="nav-status">
16005 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
16006 <div class="nav-dropdown">
16007 <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>
16008 <div class="nav-dropdown-menu">
16009 <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>
16010 </div>
16011 </div>
16012 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
16013 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16014 <div class="nav-dropdown">
16015 <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>
16016 <div class="nav-dropdown-menu">
16017 <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>
16018 </div>
16019 </div>
16020 <div class="server-status-wrap" id="server-status-wrap">
16021 <div class="nav-pill server-online-pill" id="server-status-pill">
16022 <span class="status-dot" id="status-dot"></span>
16023 <span id="server-status-label">Server</span>
16024 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16025 </div>
16026 <div class="server-status-tip">
16027 OxideSLOC is running — accessible on your network.
16028 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16029 </div>
16030 </div>
16031 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16032 <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>
16033 </button>
16034 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16035 <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>
16036 <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>
16037 </button>
16038 </div>
16039 </div>
16040 </div>
16041
16042 <div class="page">
16043 <section class="hero">
16044 <div class="hero-top">
16045 <div>
16046 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
16047 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
16048 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
16049 <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>
16050 </div>
16051 </div>
16052 <div class="hero-quick-actions">
16053 {% if server_mode %}
16054 <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>
16055 {% else %}
16056 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
16057 {% endif %}
16058 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
16059 {% if !server_mode %}
16060 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
16061 {% endif %}
16062 </div>
16063 </div>
16064
16065 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
16066 <div class="run-id-row">
16067 <span class="run-id-chip" data-copy="{{ run_id }}">
16068 <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>
16069 <span class="run-id-chip-value">{{ run_id }}</span>
16070 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
16071 </span>
16072 {% match git_commit_long %}
16073 {% when Some with (long_sha) %}
16074 {% match git_commit_url %}
16075 {% when Some with (commit_url) %}
16076 <span class="run-id-chip" data-copy="{{ long_sha }}">
16077 <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>
16078 <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
16079 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
16080 </span>
16081 {% when None %}
16082 <span class="run-id-chip" data-copy="{{ long_sha }}">
16083 <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>
16084 <span class="run-id-chip-value">{{ long_sha }}</span>
16085 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
16086 </span>
16087 {% endmatch %}
16088 {% when None %}
16089 <span class="run-id-chip muted-chip">
16090 <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>
16091 <span class="run-id-chip-value">Not detected</span>
16092 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
16093 </span>
16094 {% endmatch %}
16095 {% match git_branch %}
16096 {% when Some with (branch) %}
16097 <span class="run-id-chip">
16098 <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>
16099 <span class="run-id-chip-value">{{ branch }}</span>
16100 <span class="chip-tooltip">Git branch active at scan time</span>
16101 </span>
16102 {% when None %}
16103 <span class="run-id-chip muted-chip">
16104 <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>
16105 <span class="run-id-chip-value">Not detected</span>
16106 <span class="chip-tooltip">No Git branch was found for this scan</span>
16107 </span>
16108 {% endmatch %}
16109 {% match git_author %}
16110 {% when Some with (author) %}
16111 <span class="run-id-chip" data-author="{{ author }}">
16112 <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>
16113 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
16114 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
16115 </span>
16116 {% when None %}
16117 <span class="run-id-chip muted-chip">
16118 <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>
16119 <span class="run-id-chip-value">Not detected</span>
16120 <span class="chip-tooltip">No commit author was found for this scan</span>
16121 </span>
16122 {% endmatch %}
16123 </div>
16124
16125 <!-- Scan metadata row -->
16126 <div class="meta">
16127 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
16128 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
16129 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
16130 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
16131 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
16132 </div>
16133
16134 <!-- 12 summary stat chips -->
16135 <div class="summary-strip">
16136 <div class="stat-chip" data-raw="{{ physical_lines }}">
16137 <div class="stat-chip-label">Physical lines</div>
16138 <div class="stat-chip-val">{{ physical_lines }}</div>
16139 <div class="stat-chip-exact"></div>
16140 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
16141 </div>
16142 <div class="stat-chip" data-raw="{{ code_lines }}">
16143 <div class="stat-chip-label">Code</div>
16144 <div class="stat-chip-val">{{ code_lines }}</div>
16145 <div class="stat-chip-exact"></div>
16146 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
16147 </div>
16148 <div class="stat-chip" data-raw="{{ comment_lines }}">
16149 <div class="stat-chip-label">Comments</div>
16150 <div class="stat-chip-val">{{ comment_lines }}</div>
16151 <div class="stat-chip-exact"></div>
16152 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
16153 </div>
16154 <div class="stat-chip" data-raw="{{ blank_lines }}">
16155 <div class="stat-chip-label">Blank</div>
16156 <div class="stat-chip-val">{{ blank_lines }}</div>
16157 <div class="stat-chip-exact"></div>
16158 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
16159 </div>
16160 <div class="stat-chip" data-raw="{{ mixed_lines }}">
16161 <div class="stat-chip-label">Mixed separate</div>
16162 <div class="stat-chip-val">{{ mixed_lines }}</div>
16163 <div class="stat-chip-exact"></div>
16164 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
16165 </div>
16166 <div class="stat-chip" data-raw="{{ functions }}">
16167 <div class="stat-chip-label">Functions</div>
16168 <div class="stat-chip-val">{{ functions }}</div>
16169 <div class="stat-chip-exact"></div>
16170 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
16171 </div>
16172 <div class="stat-chip" data-raw="{{ classes }}">
16173 <div class="stat-chip-label">Classes / Types</div>
16174 <div class="stat-chip-val">{{ classes }}</div>
16175 <div class="stat-chip-exact"></div>
16176 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
16177 </div>
16178 <div class="stat-chip" data-raw="{{ variables }}">
16179 <div class="stat-chip-label">Variables</div>
16180 <div class="stat-chip-val">{{ variables }}</div>
16181 <div class="stat-chip-exact"></div>
16182 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
16183 </div>
16184 <div class="stat-chip" data-raw="{{ imports }}">
16185 <div class="stat-chip-label">Imports</div>
16186 <div class="stat-chip-val">{{ imports }}</div>
16187 <div class="stat-chip-exact"></div>
16188 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
16189 </div>
16190 <div class="stat-chip" data-raw="{{ test_count }}">
16191 <div class="stat-chip-label">Tests</div>
16192 <div class="stat-chip-val">{{ test_count }}</div>
16193 <div class="stat-chip-exact"></div>
16194 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
16195 </div>
16196 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
16197 <div class="stat-chip-label">Code density</div>
16198 <div class="stat-chip-val stat-chip-density-val">—</div>
16199 <div class="stat-chip-exact"></div>
16200 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
16201 </div>
16202 <div class="stat-chip" data-raw="{{ files_analyzed }}">
16203 <div class="stat-chip-label">Files analyzed</div>
16204 <div class="stat-chip-val">{{ files_analyzed }}</div>
16205 <div class="stat-chip-exact"></div>
16206 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
16207 </div>
16208 </div>
16209
16210 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
16211 <div class="compare-banner">
16212 <div class="compare-banner-body">
16213 <div class="compare-banner-meta">
16214 <span class="compare-label">Previous scan</span>
16215 <span class="compare-ts">{{ prev_ts }}</span>
16216 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
16217 {% if let Some(prev_code) = prev_run_code_lines %}
16218 <div class="compare-banner-stats" style="margin-top:4px;">
16219 <span>Code before: <strong>{{ prev_code }}</strong></span>
16220 <span class="compare-arrow">→</span>
16221 <span>Code now: <strong>{{ code_lines }}</strong></span>
16222 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
16223 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
16224 </div>
16225 {% endif %}
16226 </div>
16227 {% if delta_lines_added.is_some() %}
16228 <div class="delta-cards-inline">
16229 <div class="delta-card-inline">
16230 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
16231 <div class="delta-card-lbl">lines added</div>
16232 <div class="delta-card-tip">Code lines added since the previous scan</div>
16233 </div>
16234 <div class="delta-card-inline">
16235 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
16236 <div class="delta-card-lbl">lines removed</div>
16237 <div class="delta-card-tip">Code lines removed since the previous scan</div>
16238 </div>
16239 <div class="delta-card-inline">
16240 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
16241 <div class="delta-card-lbl">unmodified lines</div>
16242 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
16243 </div>
16244 <div class="delta-card-inline">
16245 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
16246 <div class="delta-card-lbl">files modified</div>
16247 <div class="delta-card-tip">Files with at least one line changed</div>
16248 </div>
16249 <div class="delta-card-inline">
16250 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
16251 <div class="delta-card-lbl">files added</div>
16252 <div class="delta-card-tip">New files added since the previous scan</div>
16253 </div>
16254 <div class="delta-card-inline">
16255 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
16256 <div class="delta-card-lbl">files removed</div>
16257 <div class="delta-card-tip">Files deleted since the previous scan</div>
16258 </div>
16259 <div class="delta-card-inline">
16260 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
16261 <div class="delta-card-lbl">files unchanged</div>
16262 <div class="delta-card-tip">Files with no changes since the previous scan</div>
16263 </div>
16264 </div>
16265 {% else %}
16266 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
16267 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
16268 </p>
16269 {% endif %}
16270 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
16271 </div>
16272 </div>
16273 {% endif %}{% endif %}
16274
16275 <div class="action-grid">
16276 <div class="action-card">
16277 <h3>HTML report</h3>
16278 <div class="action-buttons">
16279 {% match html_url %}
16280 {% when Some with (url) %}
16281 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
16282 {% when None %}{% endmatch %}
16283 {% match html_download_url %}
16284 {% when Some with (url) %}
16285 <a class="button secondary" href="{{ url }}">Download HTML</a>
16286 {% when None %}{% endmatch %}
16287 {% match html_path %}
16288 {% when Some with (_path) %}{% when None %}{% endmatch %}
16289 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
16290 </div>
16291 </div>
16292 <div class="action-card">
16293 <h3>PDF report</h3>
16294 <div class="action-buttons">
16295 {% match pdf_url %}
16296 {% when Some with (url) %}
16297 {% if pdf_generating %}
16298 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
16299 <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>
16300 Generating PDF…
16301 </button>
16302 {% else %}
16303 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
16304 {% endif %}
16305 {% when None %}
16306 {% match html_url %}
16307 {% when Some with (_hurl) %}
16308 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
16309 <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>
16310 {% when None %}
16311 <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;">
16312 PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
16313 </p>
16314 {% endmatch %}
16315 {% endmatch %}
16316 {% match pdf_download_url %}
16317 {% when Some with (url) %}
16318 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
16319 {% when None %}{% endmatch %}
16320 {% match pdf_url %}
16321 {% when Some with (_) %}
16322 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
16323 {% when None %}{% endmatch %}
16324 </div>
16325 </div>
16326 <div class="action-card">
16327 <h3>JSON result</h3>
16328 <div class="action-buttons">
16329 {% match json_url %}
16330 {% when Some with (url) %}
16331 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
16332 {% when None %}{% endmatch %}
16333 {% match json_download_url %}
16334 {% when Some with (url) %}
16335 <a class="button secondary" href="{{ url }}">Download JSON</a>
16336 {% when None %}{% endmatch %}
16337 {% match json_path %}
16338 {% when Some with (_path) %}
16339 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
16340 {% when None %}
16341 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
16342 {% endmatch %}
16343 </div>
16344 </div>
16345 <div class="action-card">
16346 <h3>Scan config</h3>
16347 <div class="action-buttons">
16348 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
16349 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
16350 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
16351 </div>
16352 </div>
16353 {% if confluence_configured %}
16354 <div class="action-card" id="confluenceCard">
16355 <h3>Confluence</h3>
16356 <div class="action-buttons">
16357 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
16358 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
16359 </div>
16360 <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>
16361 </div>
16362 {% endif %}
16363 </div>
16364 <div class="run-mgmt-strip">
16365 <div class="run-mgmt-card">
16366 <h3>Download bundle</h3>
16367 <div class="action-buttons">
16368 <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
16369 </div>
16370 <p class="action-empty-note" style="margin-top:6px;">Downloads a .tar.gz archive containing every artifact for this run (HTML, PDF, JSON, CSV, scan config).</p>
16371 </div>
16372 <div class="run-mgmt-card" id="delete-run-card">
16373 <h3>Delete run</h3>
16374 <div class="action-buttons">
16375 <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16376 </div>
16377 <p class="action-empty-note" style="margin-top:6px;">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16378 </div>
16379 </div>
16380 {% if confluence_configured %}
16381 <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;">
16382 <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);">
16383 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16384 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16385 <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;">
16386 <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>
16387 <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;">
16388 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16389 <div style="display:flex;gap:10px;justify-content:flex-end;">
16390 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16391 <button class="button" id="confSubmitBtn" type="button">Post</button>
16392 </div>
16393 </div>
16394 </div>
16395 {% endif %}
16396 <div id="delete-run-modal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;">
16397 <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);">
16398 <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16399 <p style="font-size:13px;color:var(--text);margin:0 0 18px;">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>
16400 <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16401 <div style="display:flex;gap:10px;justify-content:flex-end;">
16402 <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16403 <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16404 </div>
16405 </div>
16406 </div>
16407 {% if !submodule_rows.is_empty() %}
16408 <div class="submodule-panel">
16409 <div class="toolbar-row">
16410 <div>
16411 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16412 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16413 </div>
16414 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16415 </div>
16416 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16417 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16418 <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>
16419 <thead>
16420 <tr>
16421 <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>
16422 <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>
16423 <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>
16424 <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>
16425 <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>
16426 <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>
16427 <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>
16428 <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>
16429 </tr>
16430 </thead>
16431 <tbody>
16432 {% for row in submodule_rows %}
16433 <tr>
16434 <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>
16435 <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>
16436 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16437 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16438 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16439 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16440 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16441 <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>
16442 </tr>
16443 {% endfor %}
16444 </tbody>
16445 </table>
16446 </div>
16447 </div>
16448 {% endif %}
16449
16450 <div class="metrics-tables-stack">
16451
16452 <div class="metrics-table-wrap">
16453 <div class="metrics-table-title">Files</div>
16454 <table class="metrics-table">
16455 <thead>
16456 <tr>
16457 <th>Metric</th>
16458 <th>This Run</th>
16459 <th>Previous</th>
16460 <th>Change</th>
16461 </tr>
16462 </thead>
16463 <tbody>
16464 <tr>
16465 <td>Files analyzed</td>
16466 <td class="mt-val-large">{{ files_analyzed }}</td>
16467 <td>{{ prev_fa_str }}</td>
16468 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16469 </tr>
16470 <tr>
16471 <td>Files skipped</td>
16472 <td>{{ files_skipped }}</td>
16473 <td>{{ prev_fs_str }}</td>
16474 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16475 </tr>
16476 <tr>
16477 <td>Files modified</td>
16478 <td class="mt-val-na">—</td>
16479 <td class="mt-val-na">—</td>
16480 <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>
16481 </tr>
16482 <tr>
16483 <td>Files unchanged</td>
16484 <td class="mt-val-na">—</td>
16485 <td class="mt-val-na">—</td>
16486 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16487 </tr>
16488 </tbody>
16489 </table>
16490 </div>
16491
16492 <div class="metrics-table-wrap">
16493 <div class="metrics-table-title">Line Counts</div>
16494 <table class="metrics-table">
16495 <thead>
16496 <tr>
16497 <th>Metric</th>
16498 <th>This Run</th>
16499 <th>Previous</th>
16500 <th>Change</th>
16501 </tr>
16502 </thead>
16503 <tbody>
16504 <tr>
16505 <td>Physical lines</td>
16506 <td class="mt-val-large">{{ physical_lines }}</td>
16507 <td>{{ prev_pl_str }}</td>
16508 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16509 </tr>
16510 <tr>
16511 <td>Code lines</td>
16512 <td class="mt-val-large">{{ code_lines }}</td>
16513 <td>{{ prev_cl_str }}</td>
16514 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16515 </tr>
16516 <tr>
16517 <td>Comment lines</td>
16518 <td>{{ comment_lines }}</td>
16519 <td>{{ prev_cml_str }}</td>
16520 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16521 </tr>
16522 <tr>
16523 <td>Blank lines</td>
16524 <td>{{ blank_lines }}</td>
16525 <td>{{ prev_bl_str }}</td>
16526 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16527 </tr>
16528 <tr>
16529 <td>Mixed (separate)</td>
16530 <td>{{ mixed_lines }}</td>
16531 <td class="mt-val-na">—</td>
16532 <td class="mt-val-na">—</td>
16533 </tr>
16534 </tbody>
16535 </table>
16536 </div>
16537
16538 <div class="metrics-tables-lower">
16539 <div class="metrics-table-wrap">
16540 <div class="metrics-table-title">Code Structure</div>
16541 <table class="metrics-table">
16542 <thead>
16543 <tr>
16544 <th>Metric</th>
16545 <th>This Run</th>
16546 </tr>
16547 </thead>
16548 <tbody>
16549 <tr>
16550 <td>Functions</td>
16551 <td>{{ functions }}</td>
16552 </tr>
16553 <tr>
16554 <td>Classes / Types</td>
16555 <td>{{ classes }}</td>
16556 </tr>
16557 <tr>
16558 <td>Variables</td>
16559 <td>{{ variables }}</td>
16560 </tr>
16561 <tr>
16562 <td>Imports</td>
16563 <td>{{ imports }}</td>
16564 </tr>
16565 </tbody>
16566 </table>
16567 </div>
16568
16569 <div class="metrics-table-wrap">
16570 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16571 <table class="metrics-table">
16572 <thead>
16573 <tr>
16574 <th>Metric</th>
16575 <th>Change</th>
16576 </tr>
16577 </thead>
16578 <tbody>
16579 <tr>
16580 <td>Lines added</td>
16581 <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>
16582 </tr>
16583 <tr>
16584 <td>Lines removed</td>
16585 <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>
16586 </tr>
16587 <tr>
16588 <td>Lines modified (net)</td>
16589 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16590 </tr>
16591 <tr>
16592 <td>Lines unmodified</td>
16593 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16594 </tr>
16595 </tbody>
16596 </table>
16597 </div>
16598 </div>
16599
16600 </div>
16601
16602 <div class="path-list">
16603 <div class="path-item">
16604 <div class="path-item-label">Project path</div>
16605 <code>{{ project_path }}</code>
16606 </div>
16607 <div class="path-item">
16608 <div class="path-item-label">Git branch</div>
16609 {% if let Some(branch) = git_branch %}
16610 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16611 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16612 {% else %}
16613 <code style="color:var(--muted)">—</code>
16614 {% endif %}
16615 </div>
16616 <div class="path-item">
16617 <div class="path-item-label">Output folder</div>
16618 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16619 </div>
16620 <div class="path-item">
16621 <div class="path-item-label">Run ID</div>
16622 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16623 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16624 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16625 </div>
16626 </div>
16627 </div>
16628 </section>
16629
16630 <div class="section-pair">
16631 <section class="panel">
16632 <div class="toolbar-row">
16633 <div>
16634 <h2>Language breakdown</h2>
16635 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16636 </div>
16637 </div>
16638 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16639 </section>
16640
16641 <section class="panel r-chart-section">
16642 <div class="toolbar-row" style="margin-bottom:16px;">
16643 <div>
16644 <h2>Visualizations</h2>
16645 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16646 </div>
16647 </div>
16648
16649 <div class="r-viz-grid">
16650 <div class="r-viz-card">
16651 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16652 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
16653 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16654 </div>
16655 <div class="r-chart-tab-bar">
16656 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16657 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16658 </div>
16659 <div class="r-chart-container" id="r-composition-chart"></div>
16660 </div>
16661 <div class="r-viz-card">
16662 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16663 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
16664 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16665 </div>
16666 <div class="r-chart-container" id="r-scatter-chart"></div>
16667 </div>
16668 {% if has_semantic_data %}
16669 <div class="r-viz-card">
16670 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16671 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16672 <select class="r-chart-select" id="r-semantic-metric">
16673 <option value="functions">Functions</option>
16674 <option value="classes">Classes</option>
16675 <option value="variables">Variables</option>
16676 <option value="imports">Imports</option>
16677 </select>
16678 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16679 </div>
16680 <div class="r-chart-container" id="r-semantic-chart"></div>
16681 </div>
16682 {% endif %}
16683 <div class="r-viz-card">
16684 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16685 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
16686 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16687 </div>
16688 <div class="r-chart-container" id="r-density-chart"></div>
16689 </div>
16690 <div class="r-viz-card">
16691 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16692 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
16693 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16694 </div>
16695 <div class="r-chart-container" id="r-avglines-chart"></div>
16696 </div>
16697 <div class="r-viz-card">
16698 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16699 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
16700 <select class="r-chart-select" id="r-sub-metric">
16701 <option value="code">Code Lines</option>
16702 <option value="comment">Comments</option>
16703 <option value="blank">Blank Lines</option>
16704 <option value="physical">Physical Lines</option>
16705 <option value="files">Files</option>
16706 </select>
16707 <select class="r-chart-select" id="r-sub-sort">
16708 <option value="desc">Value ↓</option>
16709 <option value="asc">Value ↑</option>
16710 <option value="name">Name A→Z</option>
16711 </select>
16712 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16713 </div>
16714 <div class="r-chart-container" id="r-submodule-chart"></div>
16715 </div>
16716 </div>
16717
16718 </section>
16719 </div>
16720
16721 </div>
16722
16723 <div id="r-tt" aria-hidden="true"></div>
16724
16725 <script nonce="{{ csp_nonce }}">
16726 (function () {
16727 var body = document.body;
16728 var themeToggle = document.getElementById('theme-toggle');
16729 var storageKey = 'oxide-sloc-theme';
16730
16731 function applyTheme(theme) {
16732 body.classList.toggle('dark-theme', theme === 'dark');
16733 }
16734
16735 function loadSavedTheme() {
16736 try {
16737 var saved = localStorage.getItem(storageKey);
16738 if (saved === 'dark' || saved === 'light') {
16739 applyTheme(saved);
16740 }
16741 } catch (e) {}
16742 }
16743
16744 if (themeToggle) {
16745 themeToggle.addEventListener('click', function () {
16746 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16747 applyTheme(nextTheme);
16748 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16749 });
16750 }
16751
16752 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16753 button.addEventListener('click', function () {
16754 var value = button.getAttribute('data-copy-value') || '';
16755 if (!value) return;
16756 var originalText = button.textContent;
16757 function flashSuccess() {
16758 button.textContent = 'Copied!';
16759 setTimeout(function () { button.textContent = originalText; }, 1800);
16760 }
16761 function flashFail() {
16762 button.textContent = 'Copy failed';
16763 setTimeout(function () { button.textContent = originalText; }, 2000);
16764 }
16765 if (navigator.clipboard && navigator.clipboard.writeText) {
16766 navigator.clipboard.writeText(value).then(flashSuccess, function () {
16767 fallbackCopy(value, flashSuccess, flashFail);
16768 });
16769 } else {
16770 fallbackCopy(value, flashSuccess, flashFail);
16771 }
16772 });
16773 });
16774 function fallbackCopy(text, onSuccess, onFail) {
16775 try {
16776 var ta = document.createElement('textarea');
16777 ta.value = text;
16778 ta.style.position = 'fixed';
16779 ta.style.top = '-9999px';
16780 ta.style.left = '-9999px';
16781 document.body.appendChild(ta);
16782 ta.focus();
16783 ta.select();
16784 var ok = document.execCommand('copy');
16785 document.body.removeChild(ta);
16786 if (ok) { onSuccess(); } else { onFail(); }
16787 } catch (e) { onFail(); }
16788 }
16789
16790 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16791 btn.addEventListener('click', function () {
16792 var folder = btn.getAttribute('data-folder') || '';
16793 if (!folder) return;
16794 fetch('/open-path?path=' + encodeURIComponent(folder))
16795 .then(function (r) { return r.json(); })
16796 .then(function (d) {
16797 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16798 })
16799 .catch(function () {});
16800 });
16801 });
16802
16803 loadSavedTheme();
16804
16805 // ── Compact number formatting for stat chips ──────────────────────────
16806 (function(){
16807 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
16808 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16809 var raw=parseInt(chip.getAttribute('data-raw'),10);
16810 if(isNaN(raw))return;
16811 var valEl=chip.querySelector('.stat-chip-val');
16812 if(valEl)valEl.textContent=fmt(raw);
16813 var exactEl=chip.querySelector('.stat-chip-exact');
16814 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16815 });
16816 // Code density chip
16817 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16818 var code=parseInt(chip.getAttribute('data-code'),10);
16819 var phys=parseInt(chip.getAttribute('data-physical'),10);
16820 if(isNaN(code)||isNaN(phys)||phys===0)return;
16821 var pct=(code/phys*100).toFixed(1)+'%';
16822 var valEl=chip.querySelector('.stat-chip-val');
16823 if(valEl)valEl.textContent=pct;
16824 });
16825 // Populate author handle from data-author attribute
16826 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
16827 var author=chip.getAttribute('data-author');
16828 var el=chip.querySelector('.author-handle');
16829 if(el)el.textContent='/'+author.replace(/\s+/g,'');
16830 });
16831 // Click-to-copy on run-id-chip elements
16832 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16833 chip.addEventListener('click',function(){
16834 var val=chip.getAttribute('data-copy');
16835 if(!val)return;
16836 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16837 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);}
16838 chip.classList.add('chip-copied-flash');
16839 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16840 });
16841 });
16842 })();
16843
16844 // ── Shared tooltip for all result-page charts ─────────────────────────
16845 var rTT=(function(){
16846 var el=document.getElementById('r-tt');
16847 if(!el)return{s:function(){},h:function(){},m:function(){}};
16848 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16849 function hide(){el.style.display='none';}
16850 function move(e){
16851 var x=e.clientX+16,y=e.clientY-12;
16852 var r=el.getBoundingClientRect();
16853 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16854 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16855 el.style.left=x+'px';el.style.top=y+'px';
16856 }
16857 return{s:show,h:hide,m:move};
16858 })();
16859 window.rTT=rTT;
16860
16861 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16862 (function(){
16863 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16864 document.addEventListener('mouseover',function(e){
16865 var t=e.target;
16866 while(t&&t.getAttribute){
16867 var l=t.getAttribute('data-ttl');
16868 if(l!==null){
16869 var v=t.getAttribute('data-ttv')||'';
16870 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16871 return;
16872 }
16873 t=t.parentNode;
16874 }
16875 });
16876 document.addEventListener('mouseout',function(e){
16877 var t=e.target;
16878 while(t&&t.getAttribute){
16879 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16880 t=t.parentNode;
16881 }
16882 });
16883 document.addEventListener('mousemove',function(e){
16884 var el=document.getElementById('r-tt');
16885 if(el&&el.style.display!=='none')rTT.m(e);
16886 });
16887 })();
16888
16889 // ── Language overview charts ───────────────────────────────────────────
16890 (function(){
16891 var D={{ lang_chart_json|safe }};
16892 if(!D||!D.length)return;
16893 var el=document.getElementById('result-lang-charts');
16894 if(!el)return;
16895 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16896 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16897 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16898 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
16899 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16900 function px(n){return Math.round(n);}
16901 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+'"';}
16902 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16903
16904 // Donut chart — height matches the stacked-bar chart so both panels align
16905 var rHb_d=28;
16906 var DH=Math.max(220,D.length*rHb_d+32);
16907 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16908 var legX=204,DW=360;
16909 var legCount=D.length;
16910 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16911 var legYStart=Math.round((DH-legCount*legSpacing)/2);
16912 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">';
16913 if(D.length===1){
16914 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16915 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+'"/>';
16916 } else {
16917 var ang=-Math.PI/2;
16918 D.forEach(function(d,i){
16919 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16920 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16921 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16922 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16923 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16924 var pct=Math.round(d.code/tot*100);
16925 ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+pct+'%)')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+(COLS[i%COLS.length])+'" stroke="white" stroke-width="2"/>';
16926 ang+=sw;
16927 });
16928 }
16929 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16930 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16931 D.forEach(function(d,i){
16932 var ly=legYStart+i*legSpacing;
16933 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16934 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16935 });
16936 ds+='</svg>';
16937
16938 // Horizontal stacked-bar chart — fills container width
16939 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16940 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16941 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">';
16942 D.forEach(function(d,i){
16943 var y=6+i*rHb,x=LW;
16944 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16945 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>';
16946 if(cW>0.5)bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'" rx="0"/>';x+=cW;
16947 if(cmW>0.5)bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'" rx="0"/>';x+=cmW;
16948 if(blW>0.5)bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'" rx="0"/>';
16949 bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(d.code+d.comments+d.blanks)+'</text>';
16950 });
16951 var ly=SH-14;
16952 bs+='<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"/><text x="'+(LW+13)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
16953 bs+='<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"/><text x="'+(LW+67)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
16954 bs+='<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"/><text x="'+(LW+165)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
16955 bs+='</svg>';
16956 el.innerHTML='<div class="r-lang-overview">'+
16957 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16958 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16959 '</div>';
16960 })();
16961
16962 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16963 (function(){
16964 var LANG_D={{ lang_chart_json|safe }};
16965 var SCAT_D={{ scatter_chart_json|safe }};
16966 var SEM_D={{ semantic_chart_json|safe }};
16967 var SUB_D={{ submodule_chart_json|safe }};
16968 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16969 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16970 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
16971 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16972 function px(n){return Math.round(n);}
16973 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+'"';}
16974
16975 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16976 function renderCompositionInEl(el,mode,shOvr){
16977 if(!el||!LANG_D||!LANG_D.length)return;
16978 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16979 var LW=110,SH=shOvr||224;
16980 var svgW=Math.max(320,el.offsetWidth||480);
16981 var BW=Math.max(120,svgW-LW-80);
16982 var legendH=24,topPad=4;
16983 var n=LANG_D.length||1;
16984 var rowTotal=Math.floor((SH-legendH-topPad)/n);
16985 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16986 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">';
16987 if(mode==='pct'){
16988 LANG_D.forEach(function(d,i){
16989 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16990 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16991 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16992 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>';
16993 if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
16994 if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
16995 if(blW>0.5)s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
16996 var pct=Math.round((d.code||0)/tot2*100);
16997 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16998 });
16999 } else {
17000 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
17001 LANG_D.forEach(function(d,i){
17002 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
17003 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
17004 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>';
17005 if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
17006 if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
17007 if(blW>0.5)s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
17008 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+fmt((d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
17009 });
17010 }
17011 var ly=SH-legendH+4;
17012 s+='<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"/><text x="'+(LW+13)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>';
17013 s+='<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"/><text x="'+(LW+66)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>';
17014 s+='<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"/><text x="'+(LW+165)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>';
17015 s+='</svg>';
17016 el.innerHTML=s;
17017 }
17018 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
17019 renderComposition('abs');
17020 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
17021 btn.addEventListener('click',function(){
17022 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
17023 btn.classList.add('active');
17024 renderComposition(btn.getAttribute('data-rcomp'));
17025 });
17026 });
17027
17028 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
17029 function renderScatterInEl(el,hOvr){
17030 if(!el||!SCAT_D||!SCAT_D.length)return;
17031 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
17032 var W=Math.max(320,el.offsetWidth||480);
17033 var cW=W-PL-PR,cH=H-PT-PB;
17034 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
17035 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
17036 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
17037 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">';
17038 [0,0.25,0.5,0.75,1].forEach(function(t){
17039 var y=PT+cH*(1-t);
17040 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17041 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>';
17042 });
17043 [0,0.25,0.5,0.75,1].forEach(function(t){
17044 var x=PL+cW*t;
17045 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17046 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>';
17047 });
17048 SCAT_D.forEach(function(d,i){
17049 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
17050 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
17051 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"/>';
17052 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>';
17053 });
17054 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>';
17055 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>';
17056 s+='</svg>';
17057 el.innerHTML=s;
17058 }
17059 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17060
17061 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
17062 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
17063 // the old vertical column layout on wide containers.
17064 function renderSemanticInEl(el,key,sh){
17065 if(!el||!SEM_D||!SEM_D.length)return;
17066 var n2=SEM_D.length||1;
17067 var LW=112,SH=sh||Math.max(180,n2*28+26);
17068 var svgW=Math.max(320,el.offsetWidth||480);
17069 var BW=Math.max(120,svgW-LW-80);
17070 var topPad=4,botPad=14;
17071 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
17072 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
17073 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
17074 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">';
17075 SEM_D.forEach(function(d,i){
17076 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
17077 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>';
17078 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"/>';
17079 s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(v)+'</text>';
17080 });
17081 s+='</svg>';
17082 el.innerHTML=s;
17083 }
17084 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
17085 var semSel=document.getElementById('r-semantic-metric');
17086 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
17087 var semExpand=document.getElementById('r-semantic-expand');
17088 if(semExpand){
17089 semExpand.addEventListener('click',function(){
17090 var key=semSel?semSel.value:'functions';
17091 var semLabels={'functions':'Functions','classes':'Classes / Types','variables':'Variables'};
17092 var semSubtitle=semLabels[key]||key;
17093 var n=SEM_D.length||1;
17094 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
17095 var modalH=Math.min(Math.max(360,n*38+60),maxH);
17096 var overlay=document.createElement('div');
17097 overlay.className='r-chart-modal-overlay';
17098 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><span class="r-chart-modal-title">Semantic Metrics — Full View</span><span class="r-chart-modal-subtitle">'+semSubtitle+'</span><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
17099 document.body.appendChild(overlay);
17100 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17101 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17102 var modalEl=document.getElementById('r-sem-modal-chart');
17103 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
17104 });
17105 }
17106
17107 // ── Expand buttons: re-render charts at large size inside modal ──────────
17108 (function(){
17109 function makeExpandModal(title,mH,subtitle){
17110 var overlay=document.createElement('div');
17111 overlay.className='r-chart-modal-overlay';
17112 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
17113 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><span class="r-chart-modal-title">'+title+' — Full View</span>'+subHtml+'<div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
17114 document.body.appendChild(overlay);
17115 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17116 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17117 return overlay.querySelector('.r-expand-modal-chart');
17118 }
17119 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
17120 var compExpandBtn=document.getElementById('r-composition-expand');
17121 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
17122 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
17123 var modeLabel=modeKey==='pct'?'Composition %':'Absolute Lines';
17124 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17125 var wrap=makeExpandModal('Language Composition',mH,modeLabel);
17126 if(wrap)setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
17127 });}
17128 var scatExpandBtn=document.getElementById('r-scatter-expand');
17129 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
17130 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
17131 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
17132 });}
17133 var densExpandBtn=document.getElementById('r-density-expand');
17134 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
17135 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17136 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
17137 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
17138 });}
17139 var avgExpandBtn=document.getElementById('r-avglines-expand');
17140 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
17141 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
17142 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
17143 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
17144 });}
17145 var subExpandBtn=document.getElementById('r-submodule-expand');
17146 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
17147 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
17148 var metricLabels={'code':'Code Lines','comment':'Comments','blank':'Blank Lines','physical':'Physical Lines','files':'Files'};
17149 var sortLabels={'desc':'Value ↓','asc':'Value ↑','name':'Name A→Z'};
17150 var subLabel=(metricLabels[key]||key)+' · '+(sortLabels[sort]||sort);
17151 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
17152 var wrap=makeExpandModal('Repository Overview',mH,subLabel);
17153 if(wrap)setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
17154 });}
17155 })();
17156
17157 // ── Comment Density: comments / (code + comments) per language ───────────
17158 function renderDensityInEl(el,shOvr){
17159 if(!el||!LANG_D||!LANG_D.length)return;
17160 var n=LANG_D.length||1;
17161 var LW=112,SH=shOvr||Math.max(180,n*28+26);
17162 var svgW=Math.max(320,el.offsetWidth||480);
17163 var BW=Math.max(120,svgW-LW-80);
17164 var topPad=4,botPad=26;
17165 var rowTotal=Math.floor((SH-topPad-botPad)/n);
17166 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17167 var densities=LANG_D.map(function(d){
17168 var sig=(d.code||0)+(d.comments||0);
17169 return sig>0?(d.comments||0)/sig:0;
17170 });
17171 var maxDen=Math.max.apply(null,densities)||1;
17172 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">';
17173 LANG_D.forEach(function(d,i){
17174 var den=densities[i],bw=den/maxDen*BW;
17175 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17176 var pct=Math.round(den*100);
17177 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>';
17178 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"/>';
17179 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17180 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+pct+'%</text>';
17181 });
17182 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>';
17183 s+='</svg>';
17184 el.innerHTML=s;
17185 }
17186 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
17187 renderDensity();
17188
17189 // ── Avg Lines per File: code / files per language ─────────────────────
17190 function renderAvgLinesInEl(el,shOvr){
17191 if(!el||!LANG_D||!LANG_D.length)return;
17192 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
17193 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
17194 var n=data.length||1;
17195 var LW=112,SH=shOvr||Math.max(180,n*28+26);
17196 var svgW=Math.max(320,el.offsetWidth||480);
17197 var BW=Math.max(120,svgW-LW-80);
17198 var topPad=4,botPad=26;
17199 var rowTotal=Math.floor((SH-topPad-botPad)/n);
17200 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17201 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
17202 var maxAvg=Math.max.apply(null,avgs)||1;
17203 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">';
17204 data.forEach(function(d,i){
17205 var avg=avgs[i],bw=avg/maxAvg*BW;
17206 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17207 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>';
17208 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"/>';
17209 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17210 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
17211 });
17212 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>';
17213 s+='</svg>';
17214 el.innerHTML=s;
17215 }
17216 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
17217 renderAvgLines();
17218
17219 // ── Repository Overview: overall row + per-submodule rows ────────────
17220 function renderSubmoduleInEl(el,key,sort,shOvr){
17221 if(!el)return;
17222 var overall={
17223 name:'Overall',
17224 code:LANG_D.reduce(function(s,d){return s+(d.code||0);},0),
17225 comment:LANG_D.reduce(function(s,d){return s+(d.comments||0);},0),
17226 blank:LANG_D.reduce(function(s,d){return s+(d.blanks||0);},0),
17227 physical:SCAT_D.reduce(function(s,d){return s+(d.physical||0);},0),
17228 files:LANG_D.reduce(function(s,d){return s+(d.files||0);},0),
17229 isOverall:true
17230 };
17231 var subs=SUB_D.slice();
17232 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
17233 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
17234 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
17235 var data=[overall].concat(subs);
17236 var rowH=32,bH=22,sepH=subs.length>0?14:0;
17237 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
17238 var svgW=Math.max(320,el.offsetWidth||480);
17239 var LW=116,BW=Math.max(200,svgW-LW-54);
17240 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
17241 var OVERALL_COL='#6b7280';
17242 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">';
17243 var yOff=4;
17244 data.forEach(function(d,i){
17245 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
17246 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
17247 var label=d.name||d.path||'?';
17248 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>';
17249 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
17250 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17251 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;"'+(d.isOverall?' font-weight="700"':'')+'>'+fmt(v)+'</text>';
17252 yOff+=rowH;
17253 if(d.isOverall&&subs.length>0){
17254 yOff+=sepH;
17255 }
17256 });
17257 s+='</svg>';
17258 el.innerHTML=s;
17259 }
17260 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
17261 var subSel=document.getElementById('r-sub-metric');
17262 var sortSel=document.getElementById('r-sub-sort');
17263 renderSubmodule('code','desc');
17264 if(subSel){
17265 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
17266 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
17267 }
17268
17269 // Equalise heights within each chart row: if one chart in a grid row is taller
17270 // than its neighbour, re-render the shorter one at the taller height so bars fill
17271 // the available vertical space instead of leaving a gap.
17272 function syncRowHeights(){
17273 var avgEl=document.getElementById('r-avglines-chart');
17274 var subEl=document.getElementById('r-submodule-chart');
17275 if(avgEl&&subEl){
17276 var avgSvg=avgEl.querySelector('svg');
17277 var subSvg=subEl.querySelector('svg');
17278 if(avgSvg&&subSvg){
17279 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
17280 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
17281 var key=subSel?subSel.value||'code':'code';
17282 var sort=sortSel?sortSel.value:'desc';
17283 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
17284 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
17285 }
17286 }
17287 var semEl=document.getElementById('r-semantic-chart');
17288 var denEl=document.getElementById('r-density-chart');
17289 if(semEl&&denEl){
17290 var semSvg=semEl.querySelector('svg');
17291 var denSvg=denEl.querySelector('svg');
17292 if(semSvg&&denSvg){
17293 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
17294 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
17295 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
17296 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
17297 }
17298 }
17299 }
17300 syncRowHeights();
17301
17302 // Re-render all SVG charts when the window is resized so bars fill the card.
17303 var _rResizeTimer;
17304 window.addEventListener('resize',function(){
17305 clearTimeout(_rResizeTimer);
17306 _rResizeTimer=setTimeout(function(){
17307 var rcompBtn=document.querySelector('[data-rcomp].active');
17308 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
17309 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17310 if(semSel)renderSemantic(semSel.value||'functions');
17311 renderDensity();
17312 renderAvgLines();
17313 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
17314 syncRowHeights();
17315 },120);
17316 });
17317 })();
17318
17319 (function randomizeWatermarks() {
17320 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
17321 if (!wms.length) return;
17322 var placed = [];
17323 function tooClose(top, left) {
17324 for (var i = 0; i < placed.length; i++) {
17325 var dt = Math.abs(placed[i][0] - top);
17326 var dl = Math.abs(placed[i][1] - left);
17327 if (dt < 20 && dl < 18) return true;
17328 }
17329 return false;
17330 }
17331 function pick(leftBand) {
17332 for (var attempt = 0; attempt < 50; attempt++) {
17333 var top = Math.random() * 85 + 5;
17334 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17335 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
17336 }
17337 var top = Math.random() * 85 + 5;
17338 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17339 placed.push([top, left]);
17340 return [top, left];
17341 }
17342 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
17343 var half = Math.floor(wms.length / 2);
17344 wms.forEach(function (img, i) {
17345 var pos = pick(i < half);
17346 var size = Math.floor(Math.random() * 100 + 160);
17347 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
17348 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
17349 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;
17350 });
17351 })();
17352
17353 (function spawnCodeParticles() {
17354 var container = document.getElementById('code-particles');
17355 if (!container) return;
17356 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'];
17357 for (var i = 0; i < 38; i++) {
17358 (function(idx) {
17359 var el = document.createElement('span');
17360 el.className = 'code-particle';
17361 el.textContent = snippets[idx % snippets.length];
17362 var left = Math.random() * 94 + 2;
17363 var top = Math.random() * 88 + 6;
17364 var dur = (Math.random() * 10 + 9).toFixed(1);
17365 var delay = (Math.random() * 18).toFixed(1);
17366 var rot = (Math.random() * 26 - 13).toFixed(1);
17367 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17368 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';
17369 container.appendChild(el);
17370 })(i);
17371 }
17372 })();
17373
17374 {% if pdf_generating %}
17375 // Poll for PDF readiness and swap the disabled button to a live link once done.
17376 (function() {
17377 var openBtn = document.getElementById('pdf-open-btn');
17378 var dlBtn = document.getElementById('pdf-download-btn');
17379 function checkPdf() {
17380 fetch('/api/runs/{{ run_id }}/pdf-status')
17381 .then(function(r) { return r.json(); })
17382 .then(function(d) {
17383 if (d.ready) {
17384 if (openBtn) {
17385 var a = document.createElement('a');
17386 a.className = 'button';
17387 a.id = 'pdf-open-btn';
17388 a.href = '/runs/pdf/{{ run_id }}';
17389 a.target = '_blank';
17390 a.rel = 'noopener';
17391 a.textContent = 'Open PDF';
17392 openBtn.replaceWith(a);
17393 }
17394 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
17395 } else {
17396 setTimeout(checkPdf, 3000);
17397 }
17398 })
17399 .catch(function() { setTimeout(checkPdf, 5000); });
17400 }
17401 setTimeout(checkPdf, 3000);
17402 })();
17403 {% endif %}
17404
17405 })();
17406 </script>
17407 <script nonce="{{ csp_nonce }}">
17408 (function(){
17409 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'}];
17410 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);});}
17411 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17412 function init(){
17413 var btn=document.getElementById('settings-btn');if(!btn)return;
17414 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17415 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>';
17416 document.body.appendChild(m);
17417 var g=document.getElementById('scheme-grid');
17418 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);});
17419 var cl=document.getElementById('settings-close');
17420 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);
17421 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');});
17422 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17423 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17424 }
17425 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17426 }());
17427 </script>
17428 <footer class="site-footer">
17429 local code analysis - metrics, history and reports
17430 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17431 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17432 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17433 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17434 · <a href="/api-docs" rel="noopener">REST API</a>
17435 </footer>
17436 {% if confluence_configured %}
17437 <script nonce="{{ csp_nonce }}">
17438 (function() {
17439 var postBtn = document.getElementById('postConfluenceBtn');
17440 var copyBtn = document.getElementById('copyWikiBtn');
17441 var modal = document.getElementById('confluenceModal');
17442 if (!postBtn || !modal) return;
17443
17444 postBtn.addEventListener('click', function() {
17445 document.getElementById('confStatus').style.display = 'none';
17446 modal.style.display = 'flex';
17447 });
17448 document.getElementById('confCancelBtn').addEventListener('click', function() {
17449 modal.style.display = 'none';
17450 });
17451 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17452
17453 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
17454 var btn = this;
17455 btn.disabled = true;
17456 var status = document.getElementById('confStatus');
17457 status.style.display = 'block';
17458 status.style.background = '#dbeafe';
17459 status.style.color = '#1e40af';
17460 status.textContent = 'Posting to Confluence…';
17461 var resp = await fetch('/api/confluence/post', {
17462 method: 'POST',
17463 headers: { 'Content-Type': 'application/json' },
17464 body: JSON.stringify({
17465 run_id: '{{ run_id }}',
17466 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
17467 report_url: document.getElementById('confReportUrl').value.trim() || null
17468 })
17469 });
17470 var data = await resp.json();
17471 if (data.ok) {
17472 status.style.background = '#dcfce7'; status.style.color = '#166534';
17473 status.textContent = 'Posted! Page ID: ' + data.page_id;
17474 } else {
17475 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17476 status.textContent = 'Error: ' + (data.error || 'Unknown error');
17477 }
17478 btn.disabled = false;
17479 });
17480
17481 if (copyBtn) {
17482 copyBtn.addEventListener('click', async function() {
17483 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
17484 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
17485 var text = await resp.text();
17486 try {
17487 await navigator.clipboard.writeText(text);
17488 var orig = copyBtn.textContent;
17489 copyBtn.textContent = 'Copied!';
17490 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
17491 } catch(e) {
17492 alert('Clipboard write failed — check browser permissions.');
17493 }
17494 });
17495 }
17496 })();
17497 </script>
17498 {% endif %}
17499 <script nonce="{{ csp_nonce }}">
17500 (function() {
17501 var deleteBtn = document.getElementById('delete-run-btn');
17502 var modal = document.getElementById('delete-run-modal');
17503 var cancelBtn = document.getElementById('delete-run-cancel');
17504 var confirmBtn= document.getElementById('delete-run-confirm');
17505 if (!deleteBtn || !modal) return;
17506 deleteBtn.addEventListener('click', function() {
17507 document.getElementById('delete-run-status').style.display = 'none';
17508 modal.style.display = 'flex';
17509 });
17510 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
17511 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17512 confirmBtn.addEventListener('click', async function() {
17513 confirmBtn.disabled = true;
17514 cancelBtn.disabled = true;
17515 var status = document.getElementById('delete-run-status');
17516 status.style.display = 'block';
17517 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17518 status.textContent = 'Deleting…';
17519 try {
17520 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17521 if (resp.status === 204 || resp.ok) {
17522 status.style.background = '#dcfce7'; status.style.color = '#166534';
17523 status.textContent = 'Deleted. Redirecting…';
17524 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17525 } else {
17526 var d = await resp.json().catch(function(){return {};});
17527 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17528 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17529 confirmBtn.disabled = false;
17530 cancelBtn.disabled = false;
17531 }
17532 } catch (e) {
17533 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17534 status.textContent = 'Network error: ' + String(e);
17535 confirmBtn.disabled = false;
17536 cancelBtn.disabled = false;
17537 }
17538 });
17539 })();
17540 </script>
17541 <script nonce="{{ csp_nonce }}">(function(){
17542 var bundleBtn = document.getElementById('download-bundle-btn');
17543 if (bundleBtn) {
17544 bundleBtn.addEventListener('click', function() {
17545 bundleBtn.disabled = true;
17546 var orig = bundleBtn.textContent;
17547 bundleBtn.textContent = 'Preparing…';
17548 fetch('/api/runs/{{ run_id }}/bundle')
17549 .then(function(r) {
17550 if (!r.ok) throw new Error('HTTP ' + r.status);
17551 return r.blob();
17552 })
17553 .then(function(blob) {
17554 var url = URL.createObjectURL(blob);
17555 var a = document.createElement('a');
17556 a.href = url;
17557 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17558 document.body.appendChild(a);
17559 a.click();
17560 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17561 bundleBtn.disabled = false;
17562 bundleBtn.textContent = orig;
17563 })
17564 .catch(function(e) {
17565 bundleBtn.disabled = false;
17566 bundleBtn.textContent = orig;
17567 alert('Bundle download failed: ' + String(e));
17568 });
17569 });
17570 }
17571 })();</script>
17572 <script nonce="{{ csp_nonce }}">(function(){
17573 var dot=document.getElementById('status-dot');
17574 var pingEl=document.getElementById('server-ping-ms');
17575 var tipEl=document.getElementById('server-tip-ping');
17576 var fm=document.getElementById('footer-mode');
17577 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)';}}
17578 function doPing(){
17579 var t0=performance.now();
17580 fetch('/healthz',{cache:'no-store'})
17581 .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);})
17582 .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)';}});
17583 }
17584 doPing();
17585 setInterval(doPing,5000);
17586 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');}
17587 })();</script>
17588 {% if let Some(banner) = report_header_footer %}
17589 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17590 {% endif %}
17591</body>
17592</html>
17593"##,
17594 ext = "html"
17595)]
17596#[allow(clippy::struct_excessive_bools)]
17598struct ResultTemplate {
17599 version: &'static str,
17600 report_title: String,
17601 project_path: String,
17602 output_dir: String,
17603 run_id: String,
17604 files_analyzed: u64,
17605 files_skipped: u64,
17606 physical_lines: u64,
17607 code_lines: u64,
17608 comment_lines: u64,
17609 blank_lines: u64,
17610 mixed_lines: u64,
17611 functions: u64,
17612 classes: u64,
17613 variables: u64,
17614 imports: u64,
17615 html_url: Option<String>,
17616 pdf_url: Option<String>,
17617 json_url: Option<String>,
17618 html_download_url: Option<String>,
17619 pdf_download_url: Option<String>,
17620 json_download_url: Option<String>,
17621 html_path: Option<String>,
17622 json_path: Option<String>,
17623 prev_run_id: Option<String>,
17624 prev_run_timestamp: Option<String>,
17625 prev_run_code_lines: Option<u64>,
17626 prev_fa_str: String,
17628 prev_fs_str: String,
17629 prev_pl_str: String,
17630 prev_cl_str: String,
17631 prev_cml_str: String,
17632 prev_bl_str: String,
17633 delta_fa_str: String,
17635 delta_fa_class: String,
17636 delta_fs_str: String,
17637 delta_fs_class: String,
17638 delta_pl_str: String,
17639 delta_pl_class: String,
17640 delta_cl_str: String,
17641 delta_cl_class: String,
17642 delta_cml_str: String,
17643 delta_cml_class: String,
17644 delta_bl_str: String,
17645 delta_bl_class: String,
17646 delta_lines_added: Option<i64>,
17648 delta_lines_removed: Option<i64>,
17649 delta_lines_net_str: String,
17650 delta_lines_net_class: String,
17651 delta_files_added: Option<usize>,
17652 delta_files_removed: Option<usize>,
17653 delta_files_modified: Option<usize>,
17654 delta_files_unchanged: Option<usize>,
17655 delta_unmodified_lines: Option<u64>,
17656 git_branch: Option<String>,
17658 git_commit: Option<String>,
17659 git_commit_long: Option<String>,
17660 git_author: Option<String>,
17661 git_commit_url: Option<String>,
17662 scan_performed_by: String,
17664 scan_time_display: String,
17665 os_display: String,
17666 test_count: u64,
17667 prev_scan_count: usize,
17669 current_scan_number: usize,
17670 submodule_rows: Vec<SubmoduleRow>,
17672 scan_config_url: String,
17673 lang_chart_json: String,
17674 #[allow(dead_code)]
17676 scatter_chart_json: String,
17677 #[allow(dead_code)]
17678 semantic_chart_json: String,
17679 #[allow(dead_code)]
17680 submodule_chart_json: String,
17681 #[allow(dead_code)]
17682 has_submodule_data: bool,
17683 #[allow(dead_code)]
17684 has_semantic_data: bool,
17685 pdf_generating: bool,
17686 csp_nonce: String,
17687 confluence_configured: bool,
17689 server_mode: bool,
17690 report_header_footer: Option<String>,
17692 run_id_short: String,
17693}
17694
17695#[derive(Template)]
17696#[template(
17697 source = r##"
17698<!doctype html>
17699<html lang="en">
17700<head>
17701 <meta charset="utf-8">
17702 <meta name="viewport" content="width=device-width, initial-scale=1">
17703 <title>OxideSLOC | Analyzing…</title>
17704 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17705 <style nonce="{{ csp_nonce }}">
17706 :root {
17707 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17708 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17709 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17710 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17711 }
17712 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17713 *{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;}
17714 .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);}
17715 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17716 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17717 .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));}
17718 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17719 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17720 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17721 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17722 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17723 @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; } }
17724 .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;}
17725 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17726 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17727 .page-body{padding:32px 24px 36px;}
17728 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17729 .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;}
17730 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17731 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17732 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17733 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17734 .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;}
17735 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17736 .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;}
17737 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17738 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17739 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17740 .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;}
17741 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17742 .hidden{display:none!important;}
17743 .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;}
17744 .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;}
17745 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17746 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17747 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17748 .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);}
17749 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17750 .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;}
17751 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17752 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17753 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17754 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17755 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17756 .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;}
17757 @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));}}
17758 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17759 .site-footer a{color:var(--muted);}
17760 .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;}
17761 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17762 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17763 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17764 </style>
17765</head>
17766<body>
17767 <div class="background-watermarks" aria-hidden="true">
17768 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17769 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17770 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17771 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17772 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17773 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17774 </div>
17775 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17776 <nav class="top-nav">
17777 <div class="top-nav-inner">
17778 <a href="/" class="brand">
17779 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17780 <div class="brand-copy">
17781 <h1 class="brand-title">OxideSLOC</h1>
17782 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17783 </div>
17784 </a>
17785 <div class="nav-right">
17786 <a class="nav-pill" href="/">Home</a>
17787 <div class="nav-dropdown">
17788 <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>
17789 <div class="nav-dropdown-menu">
17790 <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>
17791 </div>
17792 </div>
17793 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17794 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17795 <div class="nav-dropdown">
17796 <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>
17797 <div class="nav-dropdown-menu">
17798 <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>
17799 </div>
17800 </div>
17801 <div class="server-status-wrap" id="server-status-wrap">
17802 <div class="nav-pill server-online-pill" id="server-status-pill">
17803 <span class="status-dot" id="status-dot"></span>
17804 <span id="server-status-label">Server</span>
17805 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17806 </div>
17807 <div class="server-status-tip">
17808 OxideSLOC is running — accessible on your network.
17809 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17810 </div>
17811 </div>
17812 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17813 <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>
17814 </button>
17815 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17816 <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>
17817 <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>
17818 </button>
17819 </div>
17820 </div>
17821 </nav>
17822 <div class="page-body">
17823 <div class="wait-panel">
17824 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17825 <h2 class="wait-title">Analyzing your project…</h2>
17826 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17827 <div class="path-block">{{ project_path }}</div>
17828 <div class="metrics-row">
17829 <div class="metric-card">
17830 <div class="metric-label">Elapsed</div>
17831 <div class="metric-value" id="elapsed">0s</div>
17832 </div>
17833 <div class="metric-card">
17834 <div class="metric-label">Phase</div>
17835 <div class="metric-value" id="phase">Starting</div>
17836 </div>
17837 <div class="metric-card hidden" id="files-card">
17838 <div class="metric-label">Files</div>
17839 <div class="metric-value" id="files-progress">0</div>
17840 </div>
17841 </div>
17842 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17843 <div class="warn-slow hidden" id="warn-slow">
17844 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.
17845 </div>
17846 <div class="err-panel hidden" id="err-panel">
17847 <strong>Analysis failed</strong>
17848 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17849 </div>
17850 <div class="actions hidden" id="actions">
17851 <a href="/scan" class="btn-primary">Try Again</a>
17852 <a href="/view-reports" class="btn-outline">View Reports</a>
17853 </div>
17854 </div>
17855 </div>
17856 <script nonce="{{ csp_nonce }}">
17857 (function() {
17858 var WAIT_ID = {{ wait_id_json|safe }};
17859 var startTime = Date.now();
17860 var pollInterval = 1500;
17861 var retries = 0;
17862 var maxRetries = 5;
17863 var warnShown = false;
17864
17865 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
17866
17867 function elapsed() {
17868 return Math.floor((Date.now() - startTime) / 1000);
17869 }
17870
17871 function updateElapsed() {
17872 var s = elapsed();
17873 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17874 }
17875
17876 function setPhase(txt) {
17877 document.getElementById('phase').textContent = txt;
17878 }
17879
17880 var elapsedTimer = setInterval(updateElapsed, 1000);
17881
17882 function poll() {
17883 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17884 .then(function(r) {
17885 if (!r.ok) throw new Error('HTTP ' + r.status);
17886 return r.json();
17887 })
17888 .then(function(data) {
17889 retries = 0;
17890 if (data.state === 'complete') {
17891 clearInterval(elapsedTimer);
17892 setPhase('Done');
17893 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17894 } else if (data.state === 'failed') {
17895 clearInterval(elapsedTimer);
17896 setPhase('Failed');
17897 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17898 document.getElementById('err-panel').classList.remove('hidden');
17899 document.getElementById('actions').classList.remove('hidden');
17900 } else {
17901 // still running
17902 var s = elapsed();
17903 if (s > 90 && !warnShown) {
17904 warnShown = true;
17905 document.getElementById('warn-slow').classList.remove('hidden');
17906 }
17907 setPhase(data.phase || 'Running');
17908 var fd = data.files_done || 0, ft = data.files_total || 0;
17909 if (ft > 0) {
17910 var card = document.getElementById('files-card');
17911 if (card) card.classList.remove('hidden');
17912 var fp = document.getElementById('files-progress');
17913 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
17914 }
17915 setTimeout(poll, pollInterval);
17916 }
17917 })
17918 .catch(function(err) {
17919 retries++;
17920 if (retries >= maxRetries) {
17921 clearInterval(elapsedTimer);
17922 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17923 document.getElementById('err-panel').classList.remove('hidden');
17924 document.getElementById('actions').classList.remove('hidden');
17925 } else {
17926 // exponential back-off capped at 8s
17927 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17928 }
17929 });
17930 }
17931
17932 setTimeout(poll, pollInterval);
17933 })();
17934 </script>
17935 <footer class="site-footer">
17936 local code analysis - metrics, history and reports
17937 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17938 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17939 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17940 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17941 · <a href="/api-docs" rel="noopener">REST API</a>
17942 </footer>
17943 <script nonce="{{ csp_nonce }}">
17944 (function(){
17945 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17946 if(s==="dark")b.classList.add("dark-theme");
17947 var tt=document.getElementById("theme-toggle");
17948 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17949 })();
17950 (function spawnCodeParticles(){
17951 var c=document.getElementById('code-particles');if(!c)return;
17952 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'];
17953 for(var i=0;i<32;i++){(function(idx){
17954 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17955 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17956 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17957 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17958 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17959 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17960 c.appendChild(el);
17961 })(i);}
17962 })();
17963 (function randomizeWatermarks(){
17964 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17965 var placed=[];
17966 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;}
17967 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];}
17968 var half=Math.floor(wms.length/2);
17969 wms.forEach(function(img,i){
17970 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17971 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17972 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17973 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17974 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17975 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17976 });
17977 })();
17978 </script>
17979 <script nonce="{{ csp_nonce }}">
17980 (function(){
17981 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'}];
17982 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);});}
17983 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17984 function init(){
17985 var btn=document.getElementById('settings-btn');if(!btn)return;
17986 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17987 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>';
17988 document.body.appendChild(m);
17989 var g=document.getElementById('scheme-grid');
17990 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);});
17991 var cl=document.getElementById('settings-close');
17992 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);
17993 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');});
17994 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17995 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17996 }
17997 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17998 }());
17999 </script>
18000 <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>
18001</body>
18002</html>
18003"##,
18004 ext = "html"
18005)]
18006struct ScanWaitTemplate {
18007 version: &'static str,
18008 wait_id_json: String,
18009 project_path: String,
18010 csp_nonce: String,
18011}
18012
18013#[derive(Template)]
18014#[template(
18015 source = r##"
18016<!doctype html>
18017<html lang="en">
18018<head>
18019 <meta charset="utf-8">
18020 <meta name="viewport" content="width=device-width, initial-scale=1">
18021 <title>OxideSLOC | Error</title>
18022 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18023 <style nonce="{{ csp_nonce }}">
18024 :root {
18025 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18026 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18027 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18028 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18029 }
18030 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18031 *{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;}
18032 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18033 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18034 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18035 .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);}
18036 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18037 .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));}
18038 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18039 .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;}
18040 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18041 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18042 @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; } }
18043 .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;}
18044 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18045 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18046 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18047 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18048 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18049 .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;}
18050 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18051 .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);}
18052 .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;}
18053 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18054 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18055 .settings-modal-body{padding:14px 16px 16px;}
18056 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18057 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18058 .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;}
18059 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18060 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18061 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18062 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18063 .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;}
18064 .tz-select:focus{border-color:var(--oxide);}
18065 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18066 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18067 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18068 .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;}
18069 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18070 .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);}
18071 .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;}
18072 .btn-secondary:hover{background:var(--line);}
18073 .bug-report-wrap{margin-top:22px;border-top:1px solid var(--line);padding-top:16px;}
18074 .bug-report-wrap summary{cursor:pointer;font-size:12px;font-weight:700;color:var(--muted);list-style:none;display:inline-flex;align-items:center;gap:6px;user-select:none;padding:2px 0;}
18075 .bug-report-wrap summary::-webkit-details-marker{display:none;}
18076 .bug-report-arrow{display:inline-block;font-size:9px;transition:transform .15s ease;}
18077 .bug-report-wrap[open] .bug-report-arrow{transform:rotate(90deg);}
18078 .bug-report-wrap summary:hover{color:var(--text);}
18079 .bug-report-body{margin-top:12px;display:flex;flex-direction:column;gap:10px;}
18080 .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;}
18081 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
18082 .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;}
18083 .btn-sm:hover{background:var(--line);}
18084 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
18085 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
18086 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
18087 .bug-report-hint a:hover{text-decoration:underline;}
18088 .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;}
18089 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
18090 .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;}
18091 .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;}
18092 .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;}
18093 @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));}}
18094 .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;}
18095 </style>
18096</head>
18097<body>
18098 <div class="background-watermarks" aria-hidden="true">
18099 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18100 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18101 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18102 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18103 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18104 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18105 </div>
18106 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18107 <div class="top-nav">
18108 <div class="top-nav-inner">
18109 <a class="brand" href="/">
18110 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18111 <div class="brand-copy">
18112 <div class="brand-title">OxideSLOC</div>
18113 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18114 </div>
18115 </a>
18116 <div class="nav-right">
18117 <a class="nav-pill" href="/">Home</a>
18118 <div class="nav-dropdown">
18119 <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>
18120 <div class="nav-dropdown-menu">
18121 <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>
18122 </div>
18123 </div>
18124 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18125 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18126 <div class="nav-dropdown">
18127 <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>
18128 <div class="nav-dropdown-menu">
18129 <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>
18130 </div>
18131 </div>
18132 <div class="server-status-wrap" id="server-status-wrap">
18133 <div class="nav-pill server-online-pill" id="server-status-pill">
18134 <span class="status-dot" id="status-dot"></span>
18135 <span id="server-status-label">Server</span>
18136 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18137 </div>
18138 <div class="server-status-tip">
18139 OxideSLOC is running — accessible on your network.
18140 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18141 </div>
18142 </div>
18143 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18144 <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>
18145 </button>
18146 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18147 <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>
18148 <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>
18149 </button>
18150 </div>
18151 </div>
18152 </div>
18153
18154 <div class="page">
18155 <div class="panel">
18156 <h1>Error</h1>
18157 <div class="error-box" id="error-msg-text">{{ message }}</div>
18158 <div id="br-meta" hidden
18159 data-version="{{ version }}"
18160 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
18161 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
18162 <div class="actions">
18163 <a class="btn-primary" href="/scan">Back to setup</a>
18164 {% if let Some(report_url) = last_report_url %}
18165 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
18166 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
18167 {% else %}
18168 <a class="btn-secondary" href="/view-reports">View Reports</a>
18169 {% endif %}
18170 </div>
18171 <details class="bug-report-wrap" id="bug-report-wrap">
18172 <summary><span class="bug-report-arrow">►</span> Generate bug report</summary>
18173 <div class="bug-report-body">
18174 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
18175 <div class="bug-report-btns">
18176 <button type="button" class="btn-sm" id="bug-report-copy">
18177 <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>
18178 Copy to clipboard
18179 </button>
18180 <a class="btn-sm" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">
18181 <svg viewBox="0 0 24 24"><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>
18182 Open GitHub Issue
18183 </a>
18184 </div>
18185 <p class="bug-report-hint">Copy the report above and paste it into a new GitHub issue. Remove any file paths or project names you prefer not to share before posting.</p>
18186 </div>
18187 </details>
18188 </div>
18189 </div>
18190 <footer class="site-footer">
18191 oxide-sloc v{{ version }} — local code metrics workbench ·
18192 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18193 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18194 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18195 · <a href="/api-docs" rel="noopener">REST API</a>
18196 </footer>
18197 <script nonce="{{ csp_nonce }}">(function(){
18198 var meta=document.getElementById('br-meta');
18199 var pre=document.getElementById('bug-report-pre');
18200 var copyBtn=document.getElementById('bug-report-copy');
18201 if(!meta||!pre)return;
18202 var ver=meta.getAttribute('data-version')||'';
18203 var runId=meta.getAttribute('data-run-id')||'';
18204 var code=meta.getAttribute('data-error-code')||'';
18205 var msgEl=document.getElementById('error-msg-text');
18206 var msg=msgEl?msgEl.textContent.trim():'';
18207 function getBrowser(){
18208 var ua=navigator.userAgent;
18209 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
18210 if(!m)return 'Unknown browser';
18211 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
18212 return n+' '+m[2];
18213 }
18214 var lines=['oxide-sloc Bug Report','==============================',''];
18215 lines.push('App version: v'+ver);
18216 if(code)lines.push('HTTP status: '+code);
18217 if(runId)lines.push('Run ID: '+runId);
18218 lines.push('Page: '+window.location.pathname+(window.location.search||''));
18219 lines.push('Timestamp: '+new Date().toISOString());
18220 lines.push('Browser: '+getBrowser());
18221 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
18222 lines.push('');
18223 lines.push('Error message:');
18224 lines.push(msg);
18225 lines.push('');
18226 lines.push('Steps to reproduce:');
18227 lines.push(' 1. ');
18228 lines.push('');
18229 lines.push('Expected behavior:');
18230 lines.push(' ');
18231 pre.textContent=lines.join('\n');
18232 if(copyBtn){
18233 copyBtn.addEventListener('click',function(){
18234 var txt=pre.textContent;
18235 if(navigator.clipboard&&navigator.clipboard.writeText){
18236 navigator.clipboard.writeText(txt).then(function(){
18237 copyBtn.textContent='[OK] Copied!';
18238 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);
18239 });
18240 }else{
18241 var ta=document.createElement('textarea');
18242 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
18243 document.body.appendChild(ta);ta.select();
18244 try{document.execCommand('copy');copyBtn.textContent='[OK] Copied!';}catch(e){}
18245 document.body.removeChild(ta);
18246 }
18247 });
18248 }
18249 })();</script>
18250 <script nonce="{{ csp_nonce }}">
18251 (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");});})();
18252 (function spawnCodeParticles() {
18253 var container = document.getElementById('code-particles');
18254 if (!container) return;
18255 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'];
18256 for (var i = 0; i < 38; i++) {
18257 (function(idx) {
18258 var el = document.createElement('span');
18259 el.className = 'code-particle';
18260 el.textContent = snippets[idx % snippets.length];
18261 var left = Math.random() * 94 + 2;
18262 var top = Math.random() * 88 + 6;
18263 var dur = (Math.random() * 10 + 9).toFixed(1);
18264 var delay = (Math.random() * 18).toFixed(1);
18265 var rot = (Math.random() * 26 - 13).toFixed(1);
18266 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18267 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';
18268 container.appendChild(el);
18269 })(i);
18270 }
18271 })();
18272 (function randomizeWatermarks() {
18273 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18274 var placed = [];
18275 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; }
18276 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]; }
18277 var half = Math.floor(wms.length/2);
18278 wms.forEach(function(img, i) {
18279 var pos = pick(i < half);
18280 var w = Math.floor(Math.random()*60+80);
18281 var rot = (Math.random()*40-20).toFixed(1);
18282 var op = (Math.random()*0.08+0.05).toFixed(2);
18283 var animDur = (Math.random()*6+5).toFixed(1);
18284 var animDelay = (Math.random()*10).toFixed(1);
18285 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';
18286 });
18287 })();
18288 </script>
18289 <script nonce="{{ csp_nonce }}">
18290 (function(){
18291 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'}];
18292 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);});}
18293 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18294 function init(){
18295 var btn=document.getElementById('settings-btn');if(!btn)return;
18296 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18297 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>';
18298 document.body.appendChild(m);
18299 var g=document.getElementById('scheme-grid');
18300 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);});
18301 var cl=document.getElementById('settings-close');
18302 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);
18303 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');});
18304 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18305 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18306 }
18307 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18308 }());
18309 </script>
18310 <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>
18311</body>
18312</html>
18313"##,
18314 ext = "html"
18315)]
18316struct ErrorTemplate {
18317 message: String,
18318 last_report_url: Option<String>,
18320 last_report_label: Option<String>,
18322 run_id: Option<String>,
18324 error_code: Option<u16>,
18326 csp_nonce: String,
18327 version: &'static str,
18328}
18329
18330#[derive(Template)]
18333#[template(
18334 source = r##"
18335<!doctype html>
18336<html lang="en">
18337<head>
18338 <meta charset="utf-8">
18339 <meta name="viewport" content="width=device-width, initial-scale=1">
18340 <title>OxideSLOC | Locate Scan Files</title>
18341 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18342 <style nonce="{{ csp_nonce }}">
18343 :root {
18344 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18345 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18346 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18347 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18348 }
18349 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18350 *{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;}
18351 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18352 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18353 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18354 .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);}
18355 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18356 .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));}
18357 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18358 .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;}
18359 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18360 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
18361 @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;}}
18362 .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;}
18363 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18364 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18365 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18366 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18367 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18368 .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;}
18369 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18370 .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);}
18371 .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;}
18372 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18373 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18374 .settings-modal-body{padding:14px 16px 16px;}
18375 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18376 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18377 .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;}
18378 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18379 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18380 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18381 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18382 .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;}
18383 .tz-select:focus{border-color:var(--oxide);}
18384 .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18385 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18386 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18387 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
18388 .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;}
18389 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18390 .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;}
18391 .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;}
18392 .btn-secondary:hover{background:var(--line);}
18393 .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;}
18394 .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;}
18395 .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;}
18396 @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));}}
18397 .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;}
18398 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
18399 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
18400 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
18401 .relocate-row{display:flex;gap:8px;align-items:stretch;}
18402 .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;}
18403 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
18404 body.dark-theme .relocate-input{background:var(--surface-2);}
18405 </style>
18406</head>
18407<body>
18408 <div class="background-watermarks" aria-hidden="true">
18409 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18410 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18411 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18412 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18413 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18414 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18415 </div>
18416 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18417 <div class="top-nav">
18418 <div class="top-nav-inner">
18419 <a class="brand" href="/">
18420 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18421 <div class="brand-copy">
18422 <div class="brand-title">OxideSLOC</div>
18423 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18424 </div>
18425 </a>
18426 <div class="nav-right">
18427 <a class="nav-pill" href="/">Home</a>
18428 <div class="nav-dropdown">
18429 <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>
18430 <div class="nav-dropdown-menu">
18431 <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>
18432 </div>
18433 </div>
18434 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
18435 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18436 <div class="nav-dropdown">
18437 <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>
18438 <div class="nav-dropdown-menu">
18439 <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>
18440 </div>
18441 </div>
18442 <div class="server-status-wrap" id="server-status-wrap">
18443 <div class="nav-pill server-online-pill" id="server-status-pill">
18444 <span class="status-dot" id="status-dot"></span>
18445 <span id="server-status-label">Server</span>
18446 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18447 </div>
18448 <div class="server-status-tip">
18449 OxideSLOC is running — accessible on your network.
18450 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18451 </div>
18452 </div>
18453 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18454 <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>
18455 </button>
18456 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18457 <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>
18458 <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>
18459 </button>
18460 </div>
18461 </div>
18462 </div>
18463
18464 <div class="page">
18465 <div class="panel">
18466 <h1>Scan Files Moved</h1>
18467 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
18468 <div class="error-box">{{ message }}</div>
18469 <div class="relocate-section">
18470 <h2>Locate Scan Output</h2>
18471 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
18472 <form method="post" action="/relocate-scan">
18473 <input type="hidden" name="run_id" value="{{ run_id }}">
18474 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
18475 <div class="relocate-row">
18476 <input type="text" id="relocate-folder" name="folder_path"
18477 value="{{ folder_hint }}"
18478 placeholder="Path to folder containing scan output..."
18479 class="relocate-input" autocomplete="off" spellcheck="false">
18480 {% if !server_mode %}
18481 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
18482 {% endif %}
18483 </div>
18484 <div style="margin-top:12px;">
18485 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
18486 </div>
18487 </form>
18488 </div>
18489 <div class="actions">
18490 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
18491 <a class="btn-secondary" href="/view-reports">View Reports</a>
18492 </div>
18493 </div>
18494 </div>
18495 <script nonce="{{ csp_nonce }}">
18496 (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");});})();
18497 (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);}})();
18498 (function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));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()*60+80),rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2),dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);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 '+dur+'s ease-in-out -'+delay+'s infinite alternate';});})();
18499 </script>
18500 <script nonce="{{ csp_nonce }}">
18501 (function(){
18502 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'}];
18503 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);});}
18504 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18505 function init(){
18506 var btn=document.getElementById('settings-btn');if(!btn)return;
18507 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18508 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>';
18509 document.body.appendChild(m);
18510 var g=document.getElementById('scheme-grid');
18511 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);});
18512 var cl=document.getElementById('settings-close');
18513 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);
18514 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');});
18515 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18516 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18517 }
18518 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18519 }());
18520 (function(){
18521 var btn=document.getElementById('browse-relocate-btn');
18522 if(!btn)return;
18523 btn.addEventListener('click',function(){
18524 btn.disabled=true;btn.textContent='...';
18525 var inp=document.getElementById('relocate-folder');
18526 var hint=inp?inp.value:'';
18527 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
18528 .then(function(r){return r.ok?r.json():{cancelled:true};})
18529 .then(function(d){
18530 btn.disabled=false;btn.textContent='Browse…';
18531 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
18532 })
18533 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
18534 });
18535 }());
18536 </script>
18537 <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>
18538</body>
18539</html>
18540"##,
18541 ext = "html"
18542)]
18543struct RelocateScanTemplate {
18544 message: String,
18545 run_id: String,
18546 folder_hint: String,
18547 redirect_url: String,
18548 server_mode: bool,
18549 csp_nonce: String,
18550 version: &'static str,
18551}
18552
18553#[derive(Template)]
18556#[template(
18557 source = r##"
18558<!doctype html>
18559<html lang="en">
18560<head>
18561 <meta charset="utf-8">
18562 <meta name="viewport" content="width=device-width, initial-scale=1">
18563 <title>OxideSLOC | View Reports</title>
18564 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18565 <style nonce="{{ csp_nonce }}">
18566 :root {
18567 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18568 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18569 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18570 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18571 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
18572 }
18573 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; }
18574 *{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;}
18575 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18576 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18577 .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);}
18578 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18579 .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));}
18580 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18581 .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;}
18582 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18583 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18584 @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; } }
18585 .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;}
18586 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18587 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18588 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18589 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18590 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18591 .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;}
18592 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18593 .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);}
18594 .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;}
18595 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18596 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18597 .settings-modal-body{padding:14px 16px 16px;}
18598 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18599 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18600 .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;}
18601 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18602 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18603 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18604 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18605 .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;}
18606 .tz-select:focus{border-color:var(--oxide);}
18607 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18608 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18609 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18610 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18611 .panel-meta{font-size:13px;color:var(--muted);}
18612 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18613 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18614 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18615 .per-page-label{font-size:13px;color:var(--muted);}
18616 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;}
18617 .filter-input{min-width:180px;cursor:text;}
18618 .table-wrap{width:100%;overflow-x:auto;}
18619 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
18620 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;}
18621 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18622 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18623 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18624 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18625 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18626 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18627 tr:last-child td{border-bottom:none;}
18628 tr:hover td{background:var(--surface-2);}
18629 .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);}
18630 .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);}
18631 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18632 .metric-num{font-weight:700;color:var(--text);}
18633 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18634 .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;}
18635 .btn:hover{background:var(--line);}
18636 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18637 .btn.primary:hover{opacity:.9;}
18638 .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;}
18639 .btn-back:hover{background:var(--line);}
18640 .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;}
18641 .export-btn:hover{background:var(--line);}
18642 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18643 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18644 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18645 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18646 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18647 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18648 .pagination-info{font-size:13px;color:var(--muted);}
18649 .pagination-btns{display:flex;gap:6px;}
18650 .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;}
18651 .pg-btn:hover:not(:disabled){background:var(--line);}
18652 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18653 .pg-btn:disabled{opacity:.35;cursor:default;}
18654 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18655 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18656 .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;}
18657 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18658 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18659 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18660 .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);}
18661 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18662 .stat-chip:hover .stat-chip-tip{opacity:1;}
18663 .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;}
18664 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18665 .site-footer a{color:var(--muted);}
18666 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18667 .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%;}
18668 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18669 .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;}
18670 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18671 .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;}
18672 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18673 .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;}
18674 .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;}
18675 .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;}
18676 @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));}}
18677 .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;}
18678 .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;}
18679 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18680 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18681 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18682 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18683 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18684 .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;}
18685 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18686 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18687 .watched-chip-rm:hover{color:var(--oxide);}
18688 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18689 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18690 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18691 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18692 .rpt-btn{min-width:58px;justify-content:center;}
18693 .flex-row{display:flex;align-items:center;gap:8px;}
18694 .report-cell{overflow:visible;white-space:normal;}
18695 #history-table col:nth-child(1){width:185px;}
18696 #history-table col:nth-child(2){width:220px;}
18697 #history-table col:nth-child(3){width:100px;}
18698 #history-table col:nth-child(4){width:72px;}
18699 #history-table col:nth-child(5){width:82px;}
18700 #history-table col:nth-child(6){width:82px;}
18701 #history-table col:nth-child(7){width:65px;}
18702 #history-table col:nth-child(8){width:90px;}
18703 #history-table col:nth-child(9){width:85px;}
18704 #history-table col:nth-child(10){width:115px;}
18705 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18706 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18707 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18708 .submod-details summary::-webkit-details-marker{display:none;}
18709.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18710 .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;}
18711 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18712 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18713 </style>
18714</head>
18715<body>
18716 <div class="background-watermarks" aria-hidden="true">
18717 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18718 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18719 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18720 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18721 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18722 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18723 </div>
18724 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18725 <div class="top-nav">
18726 <div class="top-nav-inner">
18727 <a class="brand" href="/">
18728 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18729 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18730 </a>
18731 <div class="nav-right">
18732 <a class="nav-pill" href="/">Home</a>
18733 <div class="nav-dropdown">
18734 <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>
18735 <div class="nav-dropdown-menu">
18736 <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>
18737 </div>
18738 </div>
18739 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18740 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18741 <div class="nav-dropdown">
18742 <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>
18743 <div class="nav-dropdown-menu">
18744 <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>
18745 </div>
18746 </div>
18747 <div class="server-status-wrap" id="server-status-wrap">
18748 <div class="nav-pill server-online-pill" id="server-status-pill">
18749 <span class="status-dot" id="status-dot"></span>
18750 <span id="server-status-label">Server</span>
18751 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18752 </div>
18753 <div class="server-status-tip">
18754 OxideSLOC is running — accessible on your network.
18755 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18756 </div>
18757 </div>
18758 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18759 <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>
18760 </button>
18761 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18762 <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>
18763 <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>
18764 </button>
18765 </div>
18766 </div>
18767 </div>
18768
18769 <div class="page">
18770 {% if let Some(err) = browse_error %}
18771 <div class="toast-error">
18772 <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>
18773 {{ err }}
18774 </div>
18775 {% endif %}
18776 {% if linked_count > 0 %}
18777 <div class="toast-success">
18778 <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>
18779 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18780 </div>
18781 {% endif %}
18782 <div class="watched-bar">
18783 <div class="watched-bar-left">
18784 <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>
18785 <span class="watched-label">Watched Folders</span>
18786 <div class="watched-chips">
18787 {% if server_mode %}
18788 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18789 {% else %}
18790 {% for dir in watched_dirs %}
18791 <span class="watched-chip">
18792 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18793 <form method="POST" action="/watched-dirs/remove" style="display:contents">
18794 <input type="hidden" name="folder_path" value="{{ dir }}">
18795 <input type="hidden" name="redirect_to" value="/view-reports">
18796 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
18797 </form>
18798 </span>
18799 {% endfor %}
18800 {% if watched_dirs.is_empty() %}
18801 <span class="watched-none">No folders watched — click Choose to add one</span>
18802 {% endif %}
18803 {% endif %}
18804 </div>
18805 </div>
18806 {% if !server_mode %}
18807 <div class="watched-bar-right">
18808 <button type="button" class="btn" id="add-watched-btn">
18809 <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>
18810 Choose
18811 </button>
18812 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18813 <input type="hidden" name="redirect_to" value="/view-reports">
18814 <button type="submit" class="btn">↻ Refresh</button>
18815 </form>
18816 </div>
18817 {% endif %}
18818 </div>
18819 {% if total_scans > 0 %}
18820 <div class="summary-strip">
18821 <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>
18822 <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>
18823 <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>
18824 <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>
18825 </div>
18826 {% endif %}
18827
18828 <section class="panel">
18829 <div class="panel-header">
18830 <div>
18831 <h1>View Reports</h1>
18832 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18833 {% 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 %}
18834 </div>
18835 <div class="flex-row">
18836 <button type="button" class="export-btn" id="export-csv-btn">
18837 <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>
18838 Export CSV
18839 </button>
18840 <button type="button" class="export-btn" id="export-xls-btn">
18841 <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>
18842 Export Excel
18843 </button>
18844 </div>
18845 </div>
18846
18847 {% if entries.is_empty() %}
18848 <div class="empty-state">
18849 <strong>No reports with viewable HTML yet</strong>
18850 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.
18851 </div>
18852 {% else %}
18853 <div class="filter-row">
18854 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
18855 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18856 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
18857 </div>
18858 <div class="table-wrap">
18859 <table id="history-table">
18860 <colgroup>
18861 <col><col><col><col><col><col><col><col><col><col>
18862 </colgroup>
18863 <thead>
18864 <tr id="history-thead">
18865 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18866 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18867 <th>Run ID<div class="col-resize-handle"></div></th>
18868 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18869 <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>
18870 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18871 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18872 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18873 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18874 <th>Report<div class="col-resize-handle"></div></th>
18875 </tr>
18876 </thead>
18877 <tbody id="history-tbody">
18878 {% for entry in entries %}
18879 <tr class="history-row" data-run="{{ entry.run_id }}"
18880 data-timestamp="{{ entry.timestamp }}"
18881 data-project="{{ entry.project_label }}"
18882 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18883 data-skipped="{{ entry.files_skipped }}"
18884 data-comments="{{ entry.comment_lines }}"
18885 data-blank="{{ entry.blank_lines }}"
18886 data-branch="{{ entry.git_branch }}"
18887 data-commit="{{ entry.git_commit }}"
18888 data-html-url="/runs/html/{{ entry.run_id }}">
18889 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18890 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18891 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18892 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18893 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18894 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18895 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18896 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
18897 <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>
18898 <td class="report-cell">
18899 <div class="actions-cell">
18900 {% 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 %}
18901 {% 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 %}
18902 </div>
18903 {% if !entry.submodule_links.is_empty() %}
18904 <details class="submod-details">
18905 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
18906 <div class="submod-link-list">
18907 {% for sub in entry.submodule_links %}
18908 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18909 {% endfor %}
18910 </div>
18911 </details>
18912 {% endif %}
18913 </td>
18914 </tr>
18915 {% endfor %}
18916 </tbody>
18917 </table>
18918 </div>
18919 <div class="pagination">
18920 <span class="pagination-info" id="pagination-info"></span>
18921 <div class="pagination-btns" id="pagination-btns"></div>
18922 <div class="flex-row">
18923 <span class="per-page-label">Show</span>
18924 <select class="per-page" id="per-page-sel">
18925 <option value="10">10 per page</option>
18926 <option value="25" selected>25 per page</option>
18927 <option value="50">50 per page</option>
18928 <option value="100">100 per page</option>
18929 </select>
18930 <span class="per-page-label" id="page-range-label"></span>
18931 </div>
18932 </div>
18933 {% endif %}
18934 </section>
18935 </div>
18936
18937 <footer class="site-footer">
18938 local code analysis - metrics, history and reports
18939 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
18940 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18941 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18942 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18943 · <a href="/api-docs" rel="noopener">REST API</a>
18944 </footer>
18945
18946 <script nonce="{{ csp_nonce }}">
18947 (function () {
18948 // ── Theme ──────────────────────────────────────────────────────────────
18949 var storageKey = 'oxide-sloc-theme';
18950 var body = document.body;
18951 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18952 var toggle = document.getElementById('theme-toggle');
18953 if (toggle) toggle.addEventListener('click', function () {
18954 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18955 body.classList.toggle('dark-theme', next === 'dark');
18956 try { localStorage.setItem(storageKey, next); } catch(e) {}
18957 });
18958
18959 // ── State ─────────────────────────────────────────────────────────────
18960 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18961 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18962 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18963
18964 // Aggregate stats from first (most recent) row
18965 if (allRows.length) {
18966 var first = allRows[0];
18967 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
18968 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>':'');}
18969 setChipVal('agg-code', first.dataset.code);
18970 setChipVal('agg-files', first.dataset.files);
18971 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
18972 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
18973 }
18974
18975 // ── Branch filter population ──────────────────────────────────────────
18976 (function() {
18977 var branches = {};
18978 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18979 var sel = document.getElementById('branch-filter');
18980 if (sel) Object.keys(branches).sort().forEach(function(b) {
18981 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18982 });
18983 })();
18984
18985 // ── Filter ────────────────────────────────────────────────────────────
18986 function getFilteredRows() {
18987 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18988 var branch = ((document.getElementById('branch-filter') || {}).value || '');
18989 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18990 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18991 if (branch && (r.dataset.branch || '') !== branch) return false;
18992 return true;
18993 });
18994 }
18995
18996 // ── Pagination ────────────────────────────────────────────────────────
18997 function renderPage() {
18998 var filtered = getFilteredRows();
18999 var total = filtered.length;
19000 var totalPages = Math.max(1, Math.ceil(total / perPage));
19001 currentPage = Math.min(currentPage, totalPages);
19002 var start = (currentPage - 1) * perPage;
19003 var end = Math.min(start + perPage, total);
19004 var shown = {};
19005 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19006 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
19007 r.style.display = shown[r.dataset.run] ? '' : 'none';
19008 });
19009 var rl = document.getElementById('page-range-label');
19010 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19011 var info = document.getElementById('pagination-info');
19012 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19013 var btns = document.getElementById('pagination-btns');
19014 if (!btns) return;
19015 btns.innerHTML = '';
19016 function makeBtn(lbl, pg, active, disabled) {
19017 var b = document.createElement('button');
19018 b.className = 'pg-btn' + (active ? ' active' : '');
19019 b.textContent = lbl; b.disabled = disabled;
19020 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19021 return b;
19022 }
19023 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19024 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19025 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19026 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19027 }
19028
19029 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19030 window.applyFilters = function() { currentPage = 1; renderPage(); };
19031
19032 // ── Sorting ───────────────────────────────────────────────────────────
19033 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
19034 function doSort(col, type, order) {
19035 var tbody = document.getElementById('history-tbody');
19036 if (!tbody) return;
19037 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19038 rows.sort(function(a, b) {
19039 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19040 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19041 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19042 return va < vb ? 1 : va > vb ? -1 : 0;
19043 });
19044 rows.forEach(function(r) { tbody.appendChild(r); });
19045 currentPage = 1; renderPage();
19046 }
19047 sortHeaders.forEach(function(th) {
19048 th.addEventListener('click', function(e) {
19049 if (e.target.classList.contains('col-resize-handle')) return;
19050 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19051 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19052 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19053 th.classList.add('sort-' + sortOrder);
19054 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19055 doSort(col, type, sortOrder);
19056 });
19057 });
19058
19059 // ── Column resize ─────────────────────────────────────────────────────
19060 (function() {
19061 var table = document.getElementById('history-table');
19062 if (!table) return;
19063 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19064 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
19065 ths.forEach(function(th, i) {
19066 var handle = th.querySelector('.col-resize-handle');
19067 if (!handle || !cols[i]) return;
19068 var startX, startW;
19069 handle.addEventListener('mousedown', function(e) {
19070 e.stopPropagation(); e.preventDefault();
19071 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19072 handle.classList.add('dragging');
19073 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19074 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19075 document.addEventListener('mousemove', onMove);
19076 document.addEventListener('mouseup', onUp);
19077 });
19078 });
19079 })();
19080
19081 // ── Reset view ────────────────────────────────────────────────────────
19082 window.resetView = function() {
19083 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19084 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19085 sortCol = null; sortOrder = 'asc';
19086 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19087 var tbody = document.getElementById('history-tbody');
19088 if (tbody) {
19089 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19090 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19091 rows.forEach(function(r) { tbody.appendChild(r); });
19092 }
19093 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19094 var table = document.getElementById('history-table');
19095 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
19096 currentPage = 1; renderPage();
19097 };
19098
19099 renderPage();
19100
19101 // ── Export helpers ────────────────────────────────────────────────────
19102 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
19103 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
19104 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);}
19105 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;');}
19106 function slocXlsx(fname,sheet,hdrs,rows){
19107 var enc=new TextEncoder();
19108 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;}
19109 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;}
19110 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
19111 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
19112 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
19113 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;}
19114 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];}
19115 var rx='<row r="1">';
19116 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
19117 rx+='</row>';
19118 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>';});
19119 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
19120 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>';
19121 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>';
19122 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>';
19123 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>',
19124 '_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>',
19125 '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>',
19126 '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>',
19127 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
19128 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'];
19129 var zparts=[],zcds=[],zoff=0,znf=0;
19130 order.forEach(function(name){
19131 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
19132 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]);
19133 var entry=new Uint8Array(lha.length+nb.length+sz);
19134 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
19135 zparts.push(entry);
19136 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));
19137 var cde=new Uint8Array(cda.length+nb.length);
19138 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
19139 zcds.push(cde);zoff+=entry.length;znf++;
19140 });
19141 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
19142 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]);
19143 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
19144 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
19145 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
19146 zout.set(new Uint8Array(ea),zpos);
19147 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
19148 }
19149
19150 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
19151 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;}
19152 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
19153 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
19154
19155 var csvBtn = document.getElementById('export-csv-btn');
19156 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
19157 var xlsBtn = document.getElementById('export-xls-btn');
19158 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
19159
19160 // ── Remaining CSP-safe event bindings ────────────────────────────────
19161 (function wireEvents() {
19162 var el;
19163 el = document.getElementById('reset-view-btn');
19164 if (el) el.addEventListener('click', window.resetView);
19165 el = document.getElementById('project-filter');
19166 if (el) el.addEventListener('input', window.applyFilters);
19167 el = document.getElementById('branch-filter');
19168 if (el) el.addEventListener('change', window.applyFilters);
19169 el = document.getElementById('per-page-sel');
19170 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
19171 el = document.getElementById('add-watched-btn');
19172 if (el) el.addEventListener('click', function() {
19173 fetch('/pick-directory?kind=reports')
19174 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19175 .then(function(data) {
19176 if (!data.cancelled && data.selected_path) {
19177 var form = document.createElement('form');
19178 form.method = 'POST';
19179 form.action = '/watched-dirs/add';
19180 var ri = document.createElement('input');
19181 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19182 var fi = document.createElement('input');
19183 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19184 form.appendChild(ri); form.appendChild(fi);
19185 document.body.appendChild(form);
19186 form.submit();
19187 }
19188 })
19189 .catch(function(e) { alert('Could not open folder picker: ' + e); });
19190 });
19191 })();
19192
19193 (function randomizeWatermarks() {
19194 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19195 if (!wms.length) return;
19196 var placed = [];
19197 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;}
19198 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];}
19199 var half=Math.floor(wms.length/2);
19200 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;});
19201 })();
19202
19203 (function spawnCodeParticles() {
19204 var container = document.getElementById('code-particles');
19205 if (!container) return;
19206 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'];
19207 for (var i = 0; i < 38; i++) {
19208 (function(idx) {
19209 var el = document.createElement('span');
19210 el.className = 'code-particle';
19211 el.textContent = snippets[idx % snippets.length];
19212 var left = Math.random() * 94 + 2;
19213 var top = Math.random() * 88 + 6;
19214 var dur = (Math.random() * 10 + 9).toFixed(1);
19215 var delay = (Math.random() * 18).toFixed(1);
19216 var rot = (Math.random() * 26 - 13).toFixed(1);
19217 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19218 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';
19219 container.appendChild(el);
19220 })(i);
19221 }
19222 })();
19223 })();
19224 </script>
19225 <script nonce="{{ csp_nonce }}">
19226 (function(){
19227 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'}];
19228 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);});}
19229 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19230 function init(){
19231 var btn=document.getElementById('settings-btn');if(!btn)return;
19232 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19233 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>';
19234 document.body.appendChild(m);
19235 var g=document.getElementById('scheme-grid');
19236 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);});
19237 var cl=document.getElementById('settings-close');
19238 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);
19239 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');});
19240 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19241 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19242 }
19243 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19244 }());
19245 </script>
19246 <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>
19247</body>
19248</html>
19249"##,
19250 ext = "html"
19251)]
19252struct HistoryTemplate {
19253 version: &'static str,
19254 entries: Vec<HistoryEntryRow>,
19255 total_scans: usize,
19256 linked_count: usize,
19257 browse_error: Option<String>,
19258 watched_dirs: Vec<String>,
19259 csp_nonce: String,
19260 server_mode: bool,
19261}
19262
19263#[derive(Template)]
19266#[template(
19267 source = r##"
19268<!doctype html>
19269<html lang="en">
19270<head>
19271 <meta charset="utf-8">
19272 <meta name="viewport" content="width=device-width, initial-scale=1">
19273 <title>OxideSLOC | Compare Scans</title>
19274 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19275 <style nonce="{{ csp_nonce }}">
19276 :root {
19277 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
19278 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19279 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19280 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19281 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
19282 }
19283 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19284 *{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;}
19285 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19286 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19287 .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);}
19288 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19289 .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));}
19290 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19291 .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;}
19292 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19293 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19294 @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; } }
19295 .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;}
19296 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19297 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19298 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19299 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19300 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19301 .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;}
19302 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19303 .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);}
19304 .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;}
19305 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19306 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19307 .settings-modal-body{padding:14px 16px 16px;}
19308 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19309 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19310 .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;}
19311 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19312 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19313 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19314 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19315 .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;}
19316 .tz-select:focus{border-color:var(--oxide);}
19317 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19318 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19319 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
19320 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
19321 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
19322 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
19323 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
19324 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
19325 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
19326 .per-page-label{font-size:13px;color:var(--muted);}
19327 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;}
19328 .filter-input{min-width:180px;cursor:text;}
19329 .table-wrap{width:100%;overflow-x:auto;}
19330 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19331 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;}
19332 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19333 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19334 #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;}
19335 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
19336 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
19337 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
19338 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
19339 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
19340 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
19341 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
19342 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
19343 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
19344 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19345 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19346 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19347 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19348 tr:last-child td{border-bottom:none;}
19349 tr.selected td{background:var(--sel-bg);}
19350 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
19351 tr:hover:not(.selected) td{background:var(--surface-2);}
19352 tr{cursor:pointer;}
19353 .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);}
19354 .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);}
19355 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
19356 .metric-num{font-weight:700;color:var(--text);}
19357 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
19358 .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;}
19359 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
19360 .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;}
19361 .btn:hover{background:var(--line);}
19362 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
19363 .btn.primary:hover{opacity:.9;}
19364 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
19365 .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;}
19366 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
19367 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
19368 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
19369 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
19370 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
19371 .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;}
19372 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19373 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
19374 .watched-chip-rm:hover{color:var(--oxide);}
19375 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
19376 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
19377 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
19378 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
19379 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
19380 .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;}
19381 .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;}
19382 .btn-back:hover{background:var(--line);}
19383 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
19384 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
19385 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19386 .pagination-info{font-size:13px;color:var(--muted);}
19387 .pagination-btns{display:flex;gap:6px;}
19388 .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;}
19389 .pg-btn:hover:not(:disabled){background:var(--line);}
19390 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19391 .pg-btn:disabled{opacity:.35;cursor:default;}
19392 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
19393 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19394 .site-footer a{color:var(--muted);}
19395 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
19396 .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;}
19397 .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;}
19398 .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;}
19399 @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));}}
19400 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
19401 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
19402 .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;}
19403 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
19404 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
19405 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
19406 .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);}
19407 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
19408 .stat-chip:hover .stat-chip-tip{opacity:1;}
19409 .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;}
19410 .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;}
19411 .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%;}
19412 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
19413 .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;}
19414 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
19415 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
19416 .hidden{display:none!important;}
19417 .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%;}
19418 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
19419 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
19420 .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;}
19421 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19422 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
19423 .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;}
19424 .scope-option:hover{background:var(--line);}
19425 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
19426 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
19427 .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;}
19428 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
19429 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
19430 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
19431 .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;}
19432 </style>
19433</head>
19434<body>
19435 <div class="background-watermarks" aria-hidden="true">
19436 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19437 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19438 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19439 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19440 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19441 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19442 </div>
19443 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19444 <div class="top-nav">
19445 <div class="top-nav-inner">
19446 <a class="brand" href="/">
19447 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19448 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
19449 </a>
19450 <div class="nav-right">
19451 <a class="nav-pill" href="/">Home</a>
19452 <div class="nav-dropdown">
19453 <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>
19454 <div class="nav-dropdown-menu">
19455 <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>
19456 </div>
19457 </div>
19458 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19459 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19460 <div class="nav-dropdown">
19461 <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>
19462 <div class="nav-dropdown-menu">
19463 <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>
19464 </div>
19465 </div>
19466 <div class="server-status-wrap" id="server-status-wrap">
19467 <div class="nav-pill server-online-pill" id="server-status-pill">
19468 <span class="status-dot" id="status-dot"></span>
19469 <span id="server-status-label">Server</span>
19470 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19471 </div>
19472 <div class="server-status-tip">
19473 OxideSLOC is running — accessible on your network.
19474 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19475 </div>
19476 </div>
19477 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19478 <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>
19479 </button>
19480 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19481 <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>
19482 <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>
19483 </button>
19484 </div>
19485 </div>
19486 </div>
19487
19488 <div class="page">
19489 <div class="watched-bar">
19490 <div class="watched-bar-left">
19491 <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>
19492 <span class="watched-label">Watched Folders</span>
19493 <div class="watched-chips">
19494 {% if server_mode %}
19495 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
19496 {% else %}
19497 {% for dir in watched_dirs %}
19498 <span class="watched-chip">
19499 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
19500 <form method="POST" action="/watched-dirs/remove" style="display:contents">
19501 <input type="hidden" name="folder_path" value="{{ dir }}">
19502 <input type="hidden" name="redirect_to" value="/compare-scans">
19503 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
19504 </form>
19505 </span>
19506 {% endfor %}
19507 {% if watched_dirs.is_empty() %}
19508 <span class="watched-none">No folders watched — click Choose to add one</span>
19509 {% endif %}
19510 {% endif %}
19511 </div>
19512 </div>
19513 {% if !server_mode %}
19514 <div class="watched-bar-right">
19515 <button type="button" class="btn" id="add-watched-btn">
19516 <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>
19517 Choose
19518 </button>
19519 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
19520 <input type="hidden" name="redirect_to" value="/compare-scans">
19521 <button type="submit" class="btn">↻ Refresh</button>
19522 </form>
19523 </div>
19524 {% endif %}
19525 </div>
19526 {% if total_scans > 0 %}
19527 <div class="summary-strip">
19528 <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>
19529 <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>
19530 <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>
19531 <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>
19532 </div>
19533 {% endif %}
19534 <section class="panel">
19535 <div class="panel-header">
19536 <div>
19537 <h1>Compare Scans</h1>
19538 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
19539 </div>
19540 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
19541 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
19542 <button class="btn primary" id="compare-btn" disabled>
19543 <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>
19544 Compare <span class="sel-count" id="sel-count">0/2</span>
19545 </button>
19546 </div>
19547 </div>
19548 </div>
19549
19550 {% if entries.is_empty() %}
19551 <div class="empty-state">
19552 <strong>No scans yet</strong>
19553 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.
19554 </div>
19555 {% else %}
19556 <div class="filter-row">
19557 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
19558 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
19559 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
19560 </div>
19561 <div class="scope-panel hidden" id="scope-panel">
19562 <div class="scope-panel-label">
19563 <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>
19564 Compare scope — choose what to include
19565 </div>
19566 <div class="scope-options" id="scope-options"></div>
19567 </div>
19568 {% if total_scans > 0 %}
19569 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
19570 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
19571 <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>
19572 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
19573 </div>
19574 </div>
19575 {% endif %}
19576 <div class="table-wrap">
19577 <table id="compare-table">
19578 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
19579 <thead>
19580 <tr id="compare-thead">
19581 <th><div class="col-resize-handle"></div></th>
19582 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19583 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19584 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
19585 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19586 <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>
19587 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19588 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19589 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19590 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19591 <th>Submodules<div class="col-resize-handle"></div></th>
19592 </tr>
19593 </thead>
19594 <tbody id="compare-tbody">
19595 {% for entry in entries %}
19596 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
19597 data-timestamp="{{ entry.timestamp }}"
19598 data-project="{{ entry.project_label }}"
19599 data-files="{{ entry.files_analyzed }}"
19600 data-code="{{ entry.code_lines }}"
19601 data-comments="{{ entry.comment_lines }}"
19602 data-blank="{{ entry.blank_lines }}"
19603 data-branch="{{ entry.git_branch }}"
19604 data-commit="{{ entry.git_commit }}"
19605 data-submodules="{{ entry.submodule_names_csv }}">
19606 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
19607 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
19608 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
19609 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
19610 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
19611 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
19612 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
19613 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
19614 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
19615 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
19616 <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>
19617 </tr>
19618 {% endfor %}
19619 </tbody>
19620 </table>
19621 </div>
19622 <div class="pagination">
19623 <span class="pagination-info" id="pagination-info"></span>
19624 <div class="pagination-btns" id="pagination-btns"></div>
19625 <div class="flex-row">
19626 <span class="per-page-label">Show</span>
19627 <select class="per-page" id="per-page-sel">
19628 <option value="10">10 per page</option>
19629 <option value="25" selected>25 per page</option>
19630 <option value="50">50 per page</option>
19631 <option value="100">100 per page</option>
19632 </select>
19633 <span class="per-page-label" id="page-range-label"></span>
19634 </div>
19635 </div>
19636 {% endif %}
19637 </section>
19638 </div>
19639
19640 <footer class="site-footer">
19641 local code analysis - metrics, history and reports
19642 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19643 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19644 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19645 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19646 · <a href="/api-docs" rel="noopener">REST API</a>
19647 </footer>
19648
19649 <script nonce="{{ csp_nonce }}">
19650 (function () {
19651 // ── Theme ──────────────────────────────────────────────────────────────
19652 var storageKey = 'oxide-sloc-theme';
19653 var body = document.body;
19654 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19655 var toggle = document.getElementById('theme-toggle');
19656 if (toggle) toggle.addEventListener('click', function () {
19657 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19658 body.classList.toggle('dark-theme', next === 'dark');
19659 try { localStorage.setItem(storageKey, next); } catch(e) {}
19660 });
19661
19662 // ── State ─────────────────────────────────────────────────────────────
19663 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19664 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19665 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19666
19667 // ── Stat chips ────────────────────────────────────────────────────────
19668 (function() {
19669 var projects = {}, latestTs = '', latestRow = null;
19670 allRows.forEach(function(r) {
19671 var p = r.dataset.project || ''; if (p) projects[p] = true;
19672 var ts = r.dataset.timestamp || '';
19673 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19674 });
19675 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
19676 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>':'');}
19677 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19678 if (latestRow) {
19679 setChipVal('agg-code', latestRow.dataset.code);
19680 setChipVal('agg-files', latestRow.dataset.files);
19681 }
19682 })();
19683
19684 // ── Branch filter population ──────────────────────────────────────────
19685 (function() {
19686 var branches = {};
19687 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19688 var sel = document.getElementById('branch-filter');
19689 if (sel) Object.keys(branches).sort().forEach(function(b) {
19690 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19691 });
19692 })();
19693
19694 // ── Filter ────────────────────────────────────────────────────────────
19695 function getFilteredRows() {
19696 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19697 var branch = ((document.getElementById('branch-filter') || {}).value || '');
19698 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19699 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19700 if (branch && (r.dataset.branch || '') !== branch) return false;
19701 return true;
19702 });
19703 }
19704
19705 // ── Pagination ────────────────────────────────────────────────────────
19706 function renderPage() {
19707 var filtered = getFilteredRows();
19708 var total = filtered.length;
19709 var totalPages = Math.max(1, Math.ceil(total / perPage));
19710 currentPage = Math.min(currentPage, totalPages);
19711 var start = (currentPage - 1) * perPage;
19712 var end = Math.min(start + perPage, total);
19713 var shown = {};
19714 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19715 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19716 r.style.display = shown[r.dataset.run] ? '' : 'none';
19717 });
19718 var rl = document.getElementById('page-range-label');
19719 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19720 var info = document.getElementById('pagination-info');
19721 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19722 var btns = document.getElementById('pagination-btns');
19723 if (!btns) return;
19724 btns.innerHTML = '';
19725 function makeBtn(lbl, pg, active, disabled) {
19726 var b = document.createElement('button');
19727 b.className = 'pg-btn' + (active ? ' active' : '');
19728 b.textContent = lbl; b.disabled = disabled;
19729 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19730 return b;
19731 }
19732 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19733 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19734 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19735 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19736 }
19737
19738 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19739 window.applyFilters = function() { currentPage = 1; renderPage(); };
19740
19741 // ── Sorting ───────────────────────────────────────────────────────────
19742 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19743 function doSort(col, type, order) {
19744 var tbody = document.getElementById('compare-tbody');
19745 if (!tbody) return;
19746 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19747 rows.sort(function(a, b) {
19748 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19749 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19750 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19751 return va < vb ? 1 : va > vb ? -1 : 0;
19752 });
19753 rows.forEach(function(r) { tbody.appendChild(r); });
19754 currentPage = 1; renderPage();
19755 }
19756 sortHeaders.forEach(function(th) {
19757 th.addEventListener('click', function(e) {
19758 if (e.target.classList.contains('col-resize-handle')) return;
19759 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19760 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19761 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19762 th.classList.add('sort-' + sortOrder);
19763 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19764 doSort(col, type, sortOrder);
19765 });
19766 });
19767
19768 // Apply default sort (timestamp desc) on initial load
19769 (function() {
19770 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19771 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19772 })();
19773
19774 // ── Column resize ─────────────────────────────────────────────────────
19775 (function() {
19776 var table = document.getElementById('compare-table');
19777 if (!table) return;
19778 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19779 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19780 ths.forEach(function(th, i) {
19781 var handle = th.querySelector('.col-resize-handle');
19782 if (!handle || !cols[i]) return;
19783 var startX, startW;
19784 handle.addEventListener('mousedown', function(e) {
19785 e.stopPropagation(); e.preventDefault();
19786 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19787 handle.classList.add('dragging');
19788 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19789 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19790 document.addEventListener('mousemove', onMove);
19791 document.addEventListener('mouseup', onUp);
19792 });
19793 });
19794 })();
19795
19796 // ── Reset view ────────────────────────────────────────────────────────
19797 window.resetView = function() {
19798 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19799 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19800 sortCol = null; sortOrder = 'asc';
19801 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19802 var tbody = document.getElementById('compare-tbody');
19803 if (tbody) {
19804 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19805 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19806 rows.forEach(function(r) { tbody.appendChild(r); });
19807 }
19808 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19809 var table = document.getElementById('compare-table');
19810 currentPage = 1; renderPage();
19811 currentPage = 1; renderPage();
19812 };
19813
19814 renderPage();
19815
19816 // ── Row selection state ───────────────────────────────────────────────
19817 var selected = [];
19818 function updateCompareBtn() {
19819 var btn = document.getElementById('compare-btn');
19820 var cnt = document.getElementById('sel-count');
19821 if (!btn) return;
19822 btn.disabled = selected.length !== 2;
19823 if (cnt) cnt.textContent = selected.length + '/2';
19824 }
19825
19826 function toggleRow(row) {
19827 var vid = row.dataset.vid || row.dataset.run;
19828 var idx = selected.indexOf(vid);
19829 if (idx >= 0) {
19830 selected.splice(idx, 1);
19831 row.classList.remove('selected');
19832 var b = document.getElementById('badge-' + vid);
19833 if (b) b.textContent = '';
19834 } else {
19835 if (selected.length >= 2) return;
19836 selected.push(vid);
19837 row.classList.add('selected');
19838 }
19839 selected.forEach(function(v, i) {
19840 var b = document.getElementById('badge-' + v);
19841 if (b) b.textContent = i + 1;
19842 });
19843 updateCompareBtn();
19844 buildScopePanel();
19845 }
19846
19847 // ── Scope panel ───────────────────────────────────────────────────────
19848 var selectedScope = 'all';
19849
19850 function buildScopePanel() {
19851 var panel = document.getElementById('scope-panel');
19852 var opts = document.getElementById('scope-options');
19853 if (!panel || !opts) return;
19854 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19855
19856 // Collect union of submodules from both selected rows.
19857 var allSubs = {};
19858 selected.forEach(function(vid) {
19859 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19860 if (!row) return;
19861 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19862 });
19863 var subList = Object.keys(allSubs).sort();
19864 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19865
19866 panel.classList.remove('hidden');
19867 opts.innerHTML = '';
19868
19869 function makeOption(value, label, title) {
19870 var div = document.createElement('div');
19871 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19872 div.dataset.scopeValue = value;
19873 if (title) div.title = title;
19874 var radio = document.createElement('span');
19875 radio.className = 'scope-option-radio';
19876 var lbl = document.createElement('span');
19877 lbl.textContent = label;
19878 div.appendChild(radio);
19879 div.appendChild(lbl);
19880 div.addEventListener('click', function() {
19881 selectedScope = value;
19882 opts.querySelectorAll('.scope-option').forEach(function(o) {
19883 o.classList.toggle('selected', o.dataset.scopeValue === value);
19884 });
19885 });
19886 return div;
19887 }
19888
19889 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19890 var sep = document.createElement('span');
19891 sep.className = 'scope-option-sep';
19892 opts.appendChild(sep);
19893 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19894 subList.forEach(function(s) {
19895 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19896 });
19897 }
19898
19899 function doCompare() {
19900 if (selected.length !== 2) return;
19901 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19902 if (selectedScope === 'super') url += '&scope=super';
19903 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19904 window.location.href = url;
19905 }
19906
19907 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19908 var cbtn = document.getElementById('compare-btn');
19909 if (cbtn) cbtn.addEventListener('click', doCompare);
19910 var pfEl = document.getElementById('project-filter');
19911 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19912 var bfEl = document.getElementById('branch-filter');
19913 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19914 var rvBtn = document.getElementById('reset-view-btn');
19915 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19916 var ppSel = document.getElementById('per-page-sel');
19917 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19918
19919 var cmpTbody = document.getElementById('compare-tbody');
19920 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19921 var row = e.target.closest('.compare-row');
19922 if (row) toggleRow(row);
19923 });
19924
19925 (function randomizeWatermarks() {
19926 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19927 if (!wms.length) return;
19928 var placed = [];
19929 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;}
19930 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];}
19931 var half=Math.floor(wms.length/2);
19932 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;});
19933 })();
19934
19935 (function spawnCodeParticles() {
19936 var container = document.getElementById('code-particles');
19937 if (!container) return;
19938 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'];
19939 for (var i = 0; i < 38; i++) {
19940 (function(idx) {
19941 var el = document.createElement('span');
19942 el.className = 'code-particle';
19943 el.textContent = snippets[idx % snippets.length];
19944 var left = Math.random() * 94 + 2;
19945 var top = Math.random() * 88 + 6;
19946 var dur = (Math.random() * 10 + 9).toFixed(1);
19947 var delay = (Math.random() * 18).toFixed(1);
19948 var rot = (Math.random() * 26 - 13).toFixed(1);
19949 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19950 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';
19951 container.appendChild(el);
19952 })(i);
19953 }
19954 })();
19955
19956 // ── Watched folder picker ─────────────────────────────────────────────
19957 (function() {
19958 var btn = document.getElementById('add-watched-btn');
19959 if (!btn) return;
19960 btn.addEventListener('click', function() {
19961 fetch('/pick-directory?kind=reports')
19962 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19963 .then(function(data) {
19964 if (!data.cancelled && data.selected_path) {
19965 var form = document.createElement('form');
19966 form.method = 'POST';
19967 form.action = '/watched-dirs/add';
19968 var ri = document.createElement('input');
19969 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19970 var fi = document.createElement('input');
19971 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19972 form.appendChild(ri); form.appendChild(fi);
19973 document.body.appendChild(form);
19974 form.submit();
19975 }
19976 })
19977 .catch(function(e) { alert('Could not open folder picker: ' + e); });
19978 });
19979 })();
19980
19981 // ── Submodule chip truncation ─────────────────────────────────────────
19982 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19983 var chips = cell.querySelectorAll('.submod-chip');
19984 var MAX = 4;
19985 if (chips.length <= MAX) return;
19986 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19987 var badge = document.createElement('span');
19988 badge.className = 'submod-overflow-badge';
19989 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19990 badge.textContent = '+' + (chips.length - MAX) + ' more';
19991 cell.appendChild(badge);
19992 cell.style.maxHeight = 'none';
19993 });
19994 })();
19995 </script>
19996 <script nonce="{{ csp_nonce }}">
19997 (function(){
19998 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'}];
19999 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);});}
20000 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20001 function init(){
20002 var btn=document.getElementById('settings-btn');if(!btn)return;
20003 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20004 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>';
20005 document.body.appendChild(m);
20006 var g=document.getElementById('scheme-grid');
20007 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);});
20008 var cl=document.getElementById('settings-close');
20009 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);
20010 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');});
20011 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20012 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20013 }
20014 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20015 }());
20016 </script>
20017 <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>
20018</body>
20019</html>
20020"##,
20021 ext = "html"
20022)]
20023struct CompareSelectTemplate {
20024 version: &'static str,
20025 entries: Vec<HistoryEntryRow>,
20026 total_scans: usize,
20027 watched_dirs: Vec<String>,
20028 csp_nonce: String,
20029 server_mode: bool,
20030}
20031
20032#[derive(Template)]
20035#[template(
20036 source = r##"
20037<!doctype html>
20038<html lang="en">
20039<head>
20040 <meta charset="utf-8">
20041 <meta name="viewport" content="width=device-width, initial-scale=1">
20042 <title>OxideSLOC | Scan Delta</title>
20043 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20044 <style nonce="{{ csp_nonce }}">
20045 :root {
20046 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
20047 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
20048 --nav:#283790; --nav-2:#013e6b;
20049 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
20050 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
20051 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
20052 }
20053 body.dark-theme {
20054 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
20055 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
20056 }
20057 *{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;}
20058 .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);}
20059 .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;}
20060 .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));}
20061 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20062 .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;}
20063 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
20064 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20065 @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; } }
20066 .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;}
20067 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
20068 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20069 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20070 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20071 .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;}
20072 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20073 .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);}
20074 .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;}
20075 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20076 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20077 .settings-modal-body{padding:14px 16px 16px;}
20078 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20079 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20080 .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;}
20081 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20082 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20083 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20084 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20085 .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;}
20086 .tz-select:focus{border-color:var(--oxide);}
20087 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
20088 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
20089 .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;}
20090 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
20091 .hero-body{display:block;}
20092 .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;}
20093 .btn-back:hover{background:var(--line);}
20094 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
20095 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
20096 .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;}
20097 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
20098 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;}
20099 .muted{color:var(--muted);font-size:14px;}
20100 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
20101 .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;}
20102 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
20103 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
20104 .vpill-arrow{font-size:20px;color:var(--muted);}
20105 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
20106 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
20107 .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;}
20108 .delta-card.delta-card-wide{padding:22px 24px;}
20109 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
20110 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
20111 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
20112 .delta-card-from{font-size:15px;color:var(--muted);}
20113 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
20114 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
20115 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
20116 .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%;}
20117 .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;}
20118 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
20119 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
20120 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
20121 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
20122 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
20123 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
20124 .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;}
20125 .meta-card-commit:hover{color:var(--oxide);}
20126 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
20127 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
20128 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
20129 .meta-value{color:var(--text);font-size:13px;}
20130 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
20131 .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;}
20132 .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);}
20133 .delta-card:hover .dc-tip{display:block;}
20134 .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;}
20135 .export-btn:hover{background:var(--line);}
20136 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
20137 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
20138 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
20139 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
20140 .delta-card-change.zero{color:var(--muted);background:transparent;}
20141 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
20142 .delta-card-pct.pos{color:var(--pos);}
20143 .delta-card-pct.neg{color:var(--neg);}
20144 .delta-card-pct.zero{color:var(--muted);}
20145 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
20146 .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;}
20147 .insight-card.insight-flag{border-color:var(--oxide);}
20148 .insight-card:hover .dc-tip{display:block;}
20149 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
20150 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
20151 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
20152 .insight-label.flag{color:var(--oxide);}
20153 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
20154 .insight-val.pos{color:var(--pos);}
20155 .insight-val.neg{color:var(--neg);}
20156 .insight-val.high{color:#c0392a;}
20157 .insight-val.med{color:#926000;}
20158 .insight-val.low{color:var(--pos);}
20159 body.dark-theme .insight-val.high{color:#ff6b6b;}
20160 body.dark-theme .insight-val.med{color:#f0c060;}
20161 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
20162 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
20163 .fc-row{display:flex;align-items:center;gap:8px;}
20164 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
20165 .fc-label{color:var(--muted);}
20166 .fc-modified .fc-count{color:#926000;}
20167 .fc-added .fc-count{color:var(--pos);}
20168 .fc-removed .fc-count{color:var(--neg);}
20169 .fc-unchanged .fc-count{color:var(--muted);}
20170 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
20171 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
20172 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
20173 .chip.modified{background:#fff2d8;color:#926000;}
20174 .chip.added{background:#e8f5ed;color:#1a8f47;}
20175 .chip.removed{background:#fdeaea;color:#b33b3b;}
20176 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
20177 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
20178 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
20179 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
20180 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
20181 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
20182 .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;}
20183 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
20184 .tab-btn:hover:not(.active){background:var(--line);}
20185 .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;}
20186 .btn-reset:hover{background:var(--line);}
20187 .table-wrap{width:100%;overflow-x:auto;}
20188 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
20189 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;}
20190 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
20191 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
20192 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
20193 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
20194 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
20195 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20196 tr:last-child td{border-bottom:none;}
20197 tr.row-added td{background:rgba(26,143,71,0.06);}
20198 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
20199 tr.row-modified td{background:rgba(146,96,0,0.05);}
20200 tr.row-unchanged td{opacity:.6;}
20201 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
20202 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
20203 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
20204 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
20205 .status-badge.modified{background:#fff2d8;color:#926000;}
20206 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
20207 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
20208 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
20209 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
20210 .delta-val{font-weight:700;}
20211 .delta-val.pos{color:var(--pos);}
20212 .delta-val.neg{color:var(--neg);}
20213 .delta-val.zero{color:var(--muted);}
20214 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
20215 .from-to strong{color:var(--text);}
20216 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20217 .site-footer a{color:var(--muted);}
20218 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
20219 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
20220 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20221 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20222 .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;}
20223 .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;}
20224 .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;}
20225 @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));}}
20226 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
20227 .path-link:hover{color:var(--oxide-2);}
20228 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
20229 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
20230 a.vpill-id:hover{color:var(--oxide);}
20231 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
20232 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
20233 .pagination-info{font-size:13px;color:var(--muted);}
20234 .pagination-btns{display:flex;gap:6px;}
20235 .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;}
20236 .pg-btn:hover:not(:disabled){background:var(--line);}
20237 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20238 .pg-btn:disabled{opacity:.35;cursor:default;}
20239 .per-page-label{font-size:13px;color:var(--muted);}
20240 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;}
20241 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20242 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
20243 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
20244 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
20245 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
20246 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
20247 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
20248 .tab-btn.tab-unchanged{color:var(--muted);}
20249 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
20250 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
20251 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
20252 .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;}
20253 .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;}
20254 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
20255 .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;}
20256 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
20257 .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;}
20258 .submod-scope-btn:hover{background:var(--line);}
20259 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20260 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
20261 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
20262 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
20263 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
20264 body.dark-theme .ic-card{background:var(--surface-2);}
20265 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
20266 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
20267 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
20268 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
20269 #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;}
20270 </style>
20271</head>
20272<body>
20273 <div class="background-watermarks" aria-hidden="true">
20274 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20275 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20276 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20277 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20278 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20279 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20280 </div>
20281 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20282 <div class="top-nav">
20283 <div class="top-nav-inner">
20284 <a class="brand" href="/">
20285 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20286 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
20287 </a>
20288 <div class="nav-right">
20289 <a class="nav-pill" href="/">Home</a>
20290 <div class="nav-dropdown">
20291 <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>
20292 <div class="nav-dropdown-menu">
20293 <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>
20294 </div>
20295 </div>
20296 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20297 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20298 <div class="nav-dropdown">
20299 <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>
20300 <div class="nav-dropdown-menu">
20301 <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>
20302 </div>
20303 </div>
20304 <div class="server-status-wrap" id="server-status-wrap">
20305 <div class="nav-pill server-online-pill" id="server-status-pill">
20306 <span class="status-dot" id="status-dot"></span>
20307 <span id="server-status-label">Server</span>
20308 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20309 </div>
20310 <div class="server-status-tip">
20311 OxideSLOC is running — accessible on your network.
20312 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20313 </div>
20314 </div>
20315 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20316 <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>
20317 </button>
20318 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20319 <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>
20320 <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>
20321 </button>
20322 </div>
20323 </div>
20324 </div>
20325
20326 <div class="page">
20327 <section class="hero">
20328 <div class="hero-header">
20329 <div>
20330 <h1 class="delta-title">Scan Delta</h1>
20331 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
20332 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
20333 {% if let Some(sub) = active_submodule %}
20334 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
20335 {% else if super_scope_active %}
20336 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
20337 {% else %}
20338 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
20339 {% endif %}
20340 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
20341 </div>
20342 </div>
20343 <a class="btn-back" href="/compare-scans">
20344 <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>
20345 Compare Scans
20346 </a>
20347 </div>
20348 {% if has_any_submodule_data %}
20349 <div class="submod-scope-bar">
20350 <span class="submod-scope-label">
20351 <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>
20352 Scope:
20353 </span>
20354 <div class="submod-scope-divider"></div>
20355 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
20356 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
20357 title="All files — super-repo and all submodules combined">Full scan</a>
20358 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
20359 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
20360 title="Only files that are not part of any submodule">Super-repo only</a>
20361 {% for sub in submodule_options %}
20362 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
20363 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
20364 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
20365 {% endfor %}
20366 </div>
20367 {% endif %}
20368 <div class="hero-body">
20369 <div class="meta-strip">
20370 <div class="delta-card delta-card-meta">
20371 <div class="meta-card-header">
20372 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
20373 <div class="meta-card-project-col">
20374 <div class="meta-card-project">{{ project_name }}</div>
20375 {% if has_any_submodule_data %}
20376 {% if let Some(sub) = active_submodule %}
20377 <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>
20378 {% else if super_scope_active %}
20379 <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>
20380 {% else %}
20381 <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>
20382 {% endif %}
20383 {% endif %}
20384 </div>
20385 </div>
20386 {% if !baseline_git_commit.is_empty() %}
20387 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
20388 {% else %}
20389 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
20390 {% endif %}
20391 <div class="meta-card-rows">
20392 <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>
20393 <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>
20394 <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>
20395 <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>
20396 {% if let Some(tags) = baseline_git_tags %}
20397 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20398 {% endif %}
20399 </div>
20400 </div>
20401 <div class="delta-card delta-card-meta">
20402 <div class="meta-card-header">
20403 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
20404 <div class="meta-card-project-col">
20405 <div class="meta-card-project">{{ project_name }}</div>
20406 {% if has_any_submodule_data %}
20407 {% if let Some(sub) = active_submodule %}
20408 <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>
20409 {% else if super_scope_active %}
20410 <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>
20411 {% else %}
20412 <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>
20413 {% endif %}
20414 {% endif %}
20415 </div>
20416 </div>
20417 {% if !current_git_commit.is_empty() %}
20418 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
20419 {% else %}
20420 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
20421 {% endif %}
20422 <div class="meta-card-rows">
20423 <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>
20424 <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>
20425 <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>
20426 <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>
20427 {% if let Some(tags) = current_git_tags %}
20428 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20429 {% endif %}
20430 </div>
20431 </div>
20432 </div>
20433 <div class="delta-strip">
20434 <div class="delta-card">
20435 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
20436 <div class="delta-card-label">Code lines</div>
20437 <div class="delta-card-from">Before: {{ baseline_code }}</div>
20438 <div class="delta-card-to">{{ current_code }}</div>
20439 {% 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>
20440 {% 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>
20441 {% else %}<div class="delta-card-pct zero">±0%</div>
20442 {% endif %}
20443 </div>
20444 <div class="delta-card">
20445 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
20446 <div class="delta-card-label">Files analyzed</div>
20447 <div class="delta-card-from">Before: {{ baseline_files }}</div>
20448 <div class="delta-card-to">{{ current_files }}</div>
20449 {% 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>
20450 {% 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>
20451 {% else %}<div class="delta-card-pct zero">±0%</div>
20452 {% endif %}
20453 </div>
20454 <div class="delta-card">
20455 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
20456 <div class="delta-card-label">Comment lines</div>
20457 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
20458 <div class="delta-card-to">{{ current_comments }}</div>
20459 {% 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>
20460 {% 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>
20461 {% else %}<div class="delta-card-pct zero">±0%</div>
20462 {% endif %}
20463 </div>
20464 {{ coverage_delta_card|safe }}
20465 <div class="delta-card delta-card-wide">
20466 <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>
20467 <div class="delta-card-label">File changes</div>
20468 <div class="file-changes-grid">
20469 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
20470 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
20471 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
20472 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
20473 </div>
20474 </div>
20475 </div>
20476 <div class="insights-panel">
20477 <div class="insight-card">
20478 <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>
20479 <div class="insight-label">Lines Added</div>
20480 <div class="insight-val pos">+{{ code_lines_added }}</div>
20481 <div class="insight-sub">New or grown source lines</div>
20482 </div>
20483 <div class="insight-card">
20484 <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>
20485 <div class="insight-label">Lines Removed</div>
20486 <div class="insight-val neg">−{{ code_lines_removed }}</div>
20487 <div class="insight-sub">Deleted or shrunk source lines</div>
20488 </div>
20489 <div class="insight-card">
20490 <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>
20491 <div class="insight-label">Churn Rate</div>
20492 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
20493 <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>
20494 </div>
20495 {% if scope_flag %}
20496 <div class="insight-card insight-flag">
20497 <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>
20498 <div class="insight-label flag">Scope Signal</div>
20499 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
20500 <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>
20501 </div>
20502 {% endif %}
20503 </div>
20504 </div>
20505 </section>
20506
20507 <section class="panel" id="inline-charts-section">
20508 <h2>Scan Delta Charts</h2>
20509 <div class="ic-grid">
20510 <div class="ic-card">
20511 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
20512 <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>
20513 <div id="ic-c1"></div>
20514 </div>
20515 <div class="ic-card" id="ic-lang-card">
20516 <div class="ic-card-h2">Language Code Delta</div>
20517 <div id="ic-c3"></div>
20518 </div>
20519 <div class="ic-card">
20520 <div class="ic-card-h2">Delta by Metric</div>
20521 <div id="ic-c2"></div>
20522 </div>
20523 <div class="ic-card">
20524 <div class="ic-card-h2">File Change Distribution</div>
20525 <div id="ic-c4"></div>
20526 </div>
20527 </div>
20528 </section>
20529
20530 <section class="panel">
20531 <h2>File-level delta</h2>
20532 <div class="filter-tabs-row">
20533 <div class="filter-tabs">
20534 <button class="tab-btn tab-all active" data-filter="all">All</button>
20535 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
20536 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
20537 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
20538 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
20539 </div>
20540 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
20541 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
20542 <div class="export-group">
20543 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
20544 <button type="button" class="export-btn" id="delta-csv-btn">
20545 <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>
20546 CSV
20547 </button>
20548 <button type="button" class="export-btn" id="delta-xls-btn">
20549 <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>
20550 Excel
20551 </button>
20552 <button type="button" class="export-btn" id="delta-charts-btn">
20553 <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>
20554 Charts
20555 </button>
20556 </div>
20557 </div>
20558 </div>
20559
20560 <div class="table-wrap">
20561 <table id="delta-table">
20562 <colgroup>
20563 <col>
20564 <col>
20565 <col>
20566 <col>
20567 <col>
20568 <col>
20569 <col>
20570 </colgroup>
20571 <thead>
20572 <tr id="delta-thead">
20573 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
20574 <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>
20575 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
20576 <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>
20577 <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>
20578 <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>
20579 <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>
20580 </tr>
20581 </thead>
20582 <tbody id="delta-tbody">
20583 {% for row in file_rows %}
20584 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
20585 data-path="{{ row.relative_path }}"
20586 data-language="{{ row.language }}"
20587 data-baseline-code="{{ row.baseline_code }}"
20588 data-current-code="{{ row.current_code }}"
20589 data-code-delta="{{ row.code_delta_str }}"
20590 data-comment-delta="{{ row.comment_delta_str }}"
20591 data-total-delta="{{ row.total_delta_str }}"
20592 data-orig-idx="">
20593 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
20594 <td class="hide-sm">{{ row.language }}</td>
20595 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
20596 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
20597 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
20598 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
20599 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
20600 </tr>
20601 {% endfor %}
20602 </tbody>
20603 </table>
20604 </div>
20605 <div class="pagination">
20606 <span class="pagination-info" id="pg-info"></span>
20607 <div class="pagination-btns" id="pg-btns"></div>
20608 <div class="flex-row">
20609 <span class="per-page-label">Show</span>
20610 <select class="per-page" id="per-page-sel">
20611 <option value="10">10 per page</option>
20612 <option value="25" selected>25 per page</option>
20613 <option value="50">50 per page</option>
20614 <option value="100">100 per page</option>
20615 </select>
20616 <span class="per-page-label" id="pg-range-label"></span>
20617 </div>
20618 </div>
20619 </section>
20620 </div>
20621
20622 <div id="ic-tt"></div>
20623
20624 <footer class="site-footer">
20625 local code analysis - metrics, history and reports
20626 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
20627 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20628 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20629 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20630 · <a href="/api-docs" rel="noopener">REST API</a>
20631 </footer>
20632
20633 <script nonce="{{ csp_nonce }}">
20634 (function () {
20635 var storageKey = 'oxide-sloc-theme';
20636 var body = document.body;
20637 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20638 var toggle = document.getElementById('theme-toggle');
20639 if (toggle) toggle.addEventListener('click', function () {
20640 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20641 body.classList.toggle('dark-theme', next === 'dark');
20642 try { localStorage.setItem(storageKey, next); } catch(e) {}
20643 });
20644
20645 (function randomizeWatermarks() {
20646 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20647 if (!wms.length) return;
20648 var placed = [];
20649 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;}
20650 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];}
20651 var half=Math.floor(wms.length/2);
20652 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;});
20653 })();
20654
20655 (function spawnCodeParticles() {
20656 var container = document.getElementById('code-particles');
20657 if (!container) return;
20658 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'];
20659 for (var i = 0; i < 38; i++) {
20660 (function(idx) {
20661 var el = document.createElement('span');
20662 el.className = 'code-particle';
20663 el.textContent = snippets[idx % snippets.length];
20664 var left = Math.random() * 94 + 2;
20665 var top = Math.random() * 88 + 6;
20666 var dur = (Math.random() * 10 + 9).toFixed(1);
20667 var delay = (Math.random() * 18).toFixed(1);
20668 var rot = (Math.random() * 26 - 13).toFixed(1);
20669 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20670 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';
20671 container.appendChild(el);
20672 })(i);
20673 }
20674 })();
20675 })();
20676
20677 var activeStatusFilter = 'all';
20678 var deltaPerPage = 25, deltaCurrPage = 1;
20679
20680 function openFolder(path) {
20681 fetch('/open-path?path=' + encodeURIComponent(path))
20682 .then(function (r) { return r.json(); })
20683 .then(function (d) {
20684 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20685 })
20686 .catch(function () {});
20687 }
20688
20689 function getDeltaFilteredRows() {
20690 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20691 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20692 });
20693 }
20694
20695 function renderDeltaPage() {
20696 var filtered = getDeltaFilteredRows();
20697 var total = filtered.length;
20698 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20699 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20700 var start = (deltaCurrPage - 1) * deltaPerPage;
20701 var end = Math.min(start + deltaPerPage, total);
20702 var shownSet = {};
20703 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20704 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20705 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20706 });
20707 var rl = document.getElementById('pg-range-label');
20708 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20709 var info = document.getElementById('pg-info');
20710 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20711 var btns = document.getElementById('pg-btns');
20712 if (!btns) return;
20713 btns.innerHTML = '';
20714 if (totalPages <= 1) return;
20715 function makeBtn(lbl, pg, active, disabled) {
20716 var b = document.createElement('button');
20717 b.className = 'pg-btn' + (active ? ' active' : '');
20718 b.textContent = lbl; b.disabled = disabled;
20719 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20720 return b;
20721 }
20722 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20723 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20724 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20725 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20726 }
20727
20728 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20729
20730 function filterRows(status, btn) {
20731 activeStatusFilter = status;
20732 deltaCurrPage = 1;
20733 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20734 b.classList.remove('active');
20735 });
20736 if (btn) btn.classList.add('active');
20737 renderDeltaPage();
20738 }
20739
20740 // ── Sorting ──────────────────────────────────────────────────────────────
20741 var sortCol = null, sortOrder = 'asc';
20742 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20743 (function() {
20744 var tbody = document.getElementById('delta-tbody');
20745 if (!tbody) return;
20746 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20747 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20748 })();
20749
20750 function parseDeltaNum(str) {
20751 if (!str || str === '—') return 0;
20752 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20753 }
20754
20755 sortHeaders.forEach(function(th) {
20756 th.addEventListener('click', function(e) {
20757 if (e.target.classList.contains('col-resize-handle')) return;
20758 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20759 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20760 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20761 th.classList.add('sort-' + sortOrder);
20762 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20763 var tbody = document.getElementById('delta-tbody');
20764 if (!tbody) return;
20765 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20766 rows.sort(function(a, b) {
20767 var va, vb;
20768 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20769 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20770 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20771 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20772 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20773 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20774 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20775 else { va = ''; vb = ''; }
20776 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20777 return va < vb ? 1 : va > vb ? -1 : 0;
20778 });
20779 rows.forEach(function(r) { tbody.appendChild(r); });
20780 deltaCurrPage = 1;
20781 renderDeltaPage();
20782 var activeBtn = document.querySelector('.tab-btn.active');
20783 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20784 if (activeBtn) activeBtn.classList.add('active');
20785 });
20786 });
20787
20788 // ── Column resize ─────────────────────────────────────────────────────────
20789 (function() {
20790 var table = document.getElementById('delta-table');
20791 if (!table) return;
20792 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20793 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20794 ths.forEach(function(th, i) {
20795 var handle = th.querySelector('.col-resize-handle');
20796 if (!handle || !cols[i]) return;
20797 var startX, startW;
20798 handle.addEventListener('mousedown', function(e) {
20799 e.stopPropagation(); e.preventDefault();
20800 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20801 handle.classList.add('dragging');
20802 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20803 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20804 document.addEventListener('mousemove', onMove);
20805 document.addEventListener('mouseup', onUp);
20806 });
20807 });
20808 })();
20809
20810 // ── Reset ─────────────────────────────────────────────────────────────────
20811 window.resetDeltaTable = function() {
20812 sortCol = null; sortOrder = 'asc';
20813 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20814 var tbody = document.getElementById('delta-tbody');
20815 if (tbody) {
20816 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20817 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20818 rows.forEach(function(r) { tbody.appendChild(r); });
20819 }
20820 var table = document.getElementById('delta-table');
20821 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20822 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20823 activeStatusFilter = 'all';
20824 deltaCurrPage = 1;
20825 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20826 var allBtn = document.querySelector('.tab-btn');
20827 if (allBtn) allBtn.classList.add('active');
20828 renderDeltaPage();
20829 };
20830
20831 renderDeltaPage();
20832
20833 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20834 (function() {
20835 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20836 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20837 });
20838 var resetBtn = document.getElementById('delta-reset-btn');
20839 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20840 var csvBtn = document.getElementById('delta-csv-btn');
20841 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20842 var xlsBtn = document.getElementById('delta-xls-btn');
20843 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20844 var chartsBtn = document.getElementById('delta-charts-btn');
20845 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20846 var ppSel = document.getElementById('per-page-sel');
20847 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20848 var pathLink = document.getElementById('project-path-link');
20849 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20850 })();
20851
20852 // ── Export helpers ────────────────────────────────────────────────────────
20853 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
20854 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20855 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);}
20856 function slocMakeXlsx(fname,sd,dr){
20857 var enc=new TextEncoder();
20858 // CRC-32 table
20859 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;}
20860 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;}
20861 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20862 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20863 // Shared string table
20864 var ss=[],si={};
20865 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20866 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20867 // Worksheet builder — each WS() call gets its own row counter R
20868 function WS(){
20869 var R=0,buf=[];
20870 function cl(c){return String.fromCharCode(65+c);}
20871 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20872 '<v>'+S(v)+'</v></c>';}
20873 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20874 (st?' s="'+st+'"':'')+'>'+
20875 '<v>'+(+v)+'</v></c>';}
20876 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20877 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20878 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20879 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20880 '<sheetFormatPr defaultRowHeight="15"/>'+
20881 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20882 return{sc:sc,nc:nc,row:row,xml:xml};
20883 }
20884 // Language breakdown
20885 var lm={};
20886 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;});
20887 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20888 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20889 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20890 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20891 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20892 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20893 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):'';}
20894 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20895 // Summary sheet
20896 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20897 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20898 r1(s1(0,proj,2));
20899 r1(s1(0,sd.bts+' → '+sd.cts,2));
20900 r1('');
20901 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20902 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))));
20903 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))));
20904 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))));
20905 r1('');
20906 r1(s1(0,'FILE CHANGES',8));
20907 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20908 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20909 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20910 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20911 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20912 if(langs.length){
20913 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20914 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20915 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)));});
20916 }
20917 r1('');r1(s1(0,'SCAN METADATA',8));
20918 r1(s1(1,_blabel)+s1(2,_clabel));
20919 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20920 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20921 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"/>');
20922 // File Delta sheet
20923 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20924 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));
20925 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)));});
20926 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20927 // Shared strings XML
20928 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20929 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20930 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20931 // XLSX file map
20932 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20933 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>',
20934 '_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>',
20935 '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>',
20936 '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>',
20937 '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>',
20938 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20939 // ZIP packer — STORED (no compression), compatible with all XLSX readers
20940 var zparts=[],zcds=[],zoff=0,znf=0;
20941 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20942 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20943 ].forEach(function(name){
20944 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20945 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]);
20946 var entry=new Uint8Array(lha.length+nb.length+sz);
20947 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20948 zparts.push(entry);
20949 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));
20950 var cde=new Uint8Array(cda.length+nb.length);
20951 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20952 zcds.push(cde);zoff+=entry.length;znf++;
20953 });
20954 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20955 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]);
20956 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20957 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20958 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20959 zout.set(new Uint8Array(ea),zpos);
20960 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20961 var xurl=URL.createObjectURL(xblob);
20962 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20963 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20964 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20965 }
20966 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;');}
20967 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20968 function getExportFilename(ext){return _exportBase+'.'+ext;}
20969
20970 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 }}'};
20971 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;}
20972 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20973 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20974 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20975 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20976 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):'';}
20977 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20978 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)]];}
20979 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20980 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;}
20981 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20982 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20983
20984 // ── Chart HTML report ─────────────────────────────────────────────────────
20985 function slocChartReport(fname, sd, dr) {
20986 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20987 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20988 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20989 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
20990 function px(n){return Math.round(n);}
20991 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20992 // Language map
20993 var lm={};
20994 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;});
20995 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20996
20997 // Builds onmouse* attrs for interactive tooltip on each SVG element
20998 function barTT(label,val){
20999 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
21000 }
21001
21002 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
21003 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'}];
21004 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21005 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
21006 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
21007 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21008 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"/>';}
21009 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21010 c1mets.forEach(function(m,i){
21011 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21012 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21013 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>';
21014 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))+'/>';
21015 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>';
21016 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))+'/>';
21017 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>';
21018 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>';
21019 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>';
21020 });
21021 c1+='</svg>';
21022
21023 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
21024 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'}];
21025 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21026 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
21027 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
21028 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21029 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21030 mets.forEach(function(m,i){
21031 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
21032 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
21033 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
21034 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>';
21035 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
21036 if(bw>=52){
21037 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>';
21038 }else{
21039 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
21040 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>';
21041 }
21042 });
21043 c2+='</svg>';
21044
21045 // ── Chart 3: Language Code Delta ─────────────────────────────────────
21046 var c3='';
21047 if(langs.length){
21048 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21049 var C3W=550,c3LW=124,c3FW=52;
21050 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
21051 var L3rH=30,C3H=langs.length*L3rH+20;
21052 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21053 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21054 langs.forEach(function(l,i){
21055 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
21056 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
21057 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
21058 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21059 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':''))+'/>';
21060 if(bw>=48){
21061 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>';
21062 }else{
21063 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
21064 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>';
21065 }
21066 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>';
21067 });
21068 c3+='</svg>';
21069 }
21070
21071 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
21072 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;});
21073 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21074 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
21075 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21076 var ang=-Math.PI/2;
21077 segs.forEach(function(s){
21078 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21079 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
21080 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
21081 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
21082 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
21083 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)+'%')+'/>';
21084 ang+=sw;
21085 });
21086 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>';
21087 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21088 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>';});
21089 c4+='</svg>';
21090
21091 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
21092 var ttJs='var tt=document.getElementById("ox-tt");'+
21093 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
21094 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
21095 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
21096 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
21097 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
21098 'function oxHT(){tt.style.display="none";}';
21099
21100 // body max-width keeps charts from inflating beyond design dimensions on
21101 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
21102 // each chart's height blows up proportionally, breaking the one-page layout.
21103 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;}'+
21104 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
21105 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
21106 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
21107 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
21108 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
21109 'svg{display:block;}'+
21110 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
21111 '#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;}'+
21112 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
21113 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
21114 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
21115 '<div id="ox-tt"><\/div>'+
21116 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
21117 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
21118 '<div class="two-col">'+
21119 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
21120 '<div class="leg">'+
21121 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
21122 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
21123 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
21124 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
21125 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
21126 '<\/div>'+
21127 '<div class="two-col">'+
21128 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
21129 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
21130 '<\/div>'+
21131 '<script>'+ttJs+'<\/script>'+
21132 '<\/body><\/html>';
21133 slocDownload(html, fname, 'text/html;charset=utf-8;');
21134 }
21135 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
21136 // ── Inline delta charts ────────────────────────────────────────────────────
21137 var _icTT=document.getElementById('ic-tt');
21138 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
21139 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';};
21140 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
21141 (function(){
21142 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
21143 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21144 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
21145 function px(n){return Math.round(n);}
21146 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
21147 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
21148 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);});}
21149 var dr=getDeltaExportRows(),sd=_sd,lm={};
21150 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;});
21151 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
21152 // Chart 1: Baseline vs Current grouped bars
21153 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'}];
21154 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21155 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;
21156 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21157 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"/>';}
21158 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21159 c1mets.forEach(function(m,i){
21160 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21161 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21162 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>';
21163 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"/>';
21164 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>';
21165 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"/>';
21166 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>';
21167 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>';
21168 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>';
21169 });
21170 c1+='</svg>';
21171 // Chart 2: Delta by Metric
21172 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'}];
21173 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21174 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;
21175 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21176 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21177 mets.forEach(function(m,i){
21178 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);
21179 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>';
21180 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
21181 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>';}
21182 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>';}
21183 });
21184 c2+='</svg>';
21185 // Chart 3: Language Code Delta
21186 var c3='';
21187 if(langs.length){
21188 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21189 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;
21190 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21191 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21192 langs.forEach(function(l,i){
21193 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);
21194 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21195 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"/>';
21196 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>';}
21197 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>';}
21198 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>';
21199 });
21200 c3+='</svg>';
21201 }
21202 // Chart 4: File Change Donut
21203 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;});
21204 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21205 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;
21206 if(segs.length===1){
21207 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
21208 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
21209 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
21210 } else {
21211 segs.forEach(function(s){
21212 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21213 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);
21214 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);
21215 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"/>';
21216 ang+=sw;
21217 });
21218 }
21219 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>';
21220 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21221 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>';});
21222 c4+='</svg>';
21223 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
21224 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
21225 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);}
21226 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
21227 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
21228 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent=' /'+el.textContent.replace(/\s+/g,'');});
21229 })();
21230 </script>
21231 <script nonce="{{ csp_nonce }}">
21232 (function(){
21233 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'}];
21234 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);});}
21235 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21236 function init(){
21237 var btn=document.getElementById('settings-btn');if(!btn)return;
21238 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21239 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>';
21240 document.body.appendChild(m);
21241 var g=document.getElementById('scheme-grid');
21242 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);});
21243 var cl=document.getElementById('settings-close');
21244 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);
21245 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');});
21246 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21247 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21248 }
21249 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21250 }());
21251 </script>
21252 <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>
21253</body>
21254</html>
21255"##,
21256 ext = "html"
21257)]
21258#[allow(clippy::struct_excessive_bools)]
21260struct CompareTemplate {
21261 version: &'static str,
21262 project_label: String,
21263 baseline_git_commit: String,
21264 current_git_commit: String,
21265 baseline_run_id: String,
21266 current_run_id: String,
21267 baseline_run_id_short: String,
21268 current_run_id_short: String,
21269 baseline_timestamp: String,
21270 baseline_timestamp_utc_ms: i64,
21271 current_timestamp: String,
21272 current_timestamp_utc_ms: i64,
21273 project_path: String,
21274 baseline_code: u64,
21275 current_code: u64,
21276 code_lines_delta_str: String,
21277 code_lines_delta_class: String,
21278 baseline_files: u64,
21279 current_files: u64,
21280 files_analyzed_delta_str: String,
21281 files_analyzed_delta_class: String,
21282 baseline_comments: u64,
21283 current_comments: u64,
21284 comment_lines_delta_str: String,
21285 comment_lines_delta_class: String,
21286 code_lines_pct_str: String,
21287 files_analyzed_pct_str: String,
21288 comment_lines_pct_str: String,
21289 code_lines_added: i64,
21290 code_lines_removed: i64,
21291 new_scope: bool,
21293 churn_rate_str: String,
21294 churn_rate_class: String,
21295 scope_flag: bool,
21296 files_added: usize,
21297 files_removed: usize,
21298 files_modified: usize,
21299 files_unchanged: usize,
21300 file_rows: Vec<CompareFileDeltaRow>,
21301 baseline_git_author: Option<String>,
21302 current_git_author: Option<String>,
21303 baseline_git_branch: String,
21304 current_git_branch: String,
21305 baseline_git_tags: Option<String>,
21306 current_git_tags: Option<String>,
21307 baseline_git_commit_date: Option<String>,
21308 current_git_commit_date: Option<String>,
21309 project_name: String,
21310 submodule_options: Vec<String>,
21312 has_any_submodule_data: bool,
21314 active_submodule: Option<String>,
21316 super_scope_active: bool,
21318 csp_nonce: String,
21319 coverage_delta_card: String,
21321}
21322
21323#[derive(Template)]
21326#[template(
21327 source = r##"
21328<!doctype html>
21329<html lang="en">
21330<head>
21331 <meta charset="utf-8">
21332 <meta name="viewport" content="width=device-width, initial-scale=1">
21333 <title>OxideSLOC | Sign In</title>
21334 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21335 <style nonce="{{ csp_nonce }}">
21336 :root {
21337 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
21338 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
21339 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
21340 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
21341 }
21342 *{box-sizing:border-box;}
21343 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);}
21344 .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);}
21345 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
21346 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
21347 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
21348 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21349 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21350 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21351 .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;}
21352 @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));}}
21353 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
21354 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
21355 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21356 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
21357 .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;}
21358 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
21359 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;}
21360 input[type=password]:focus{border-color:var(--oxide);}
21361 .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;}
21362 .btn:hover{opacity:.88;}
21363 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
21364 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
21365 </style>
21366</head>
21367<body>
21368 <div class="background-watermarks" aria-hidden="true">
21369 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21370 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21371 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21372 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21373 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21374 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21375 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21376 </div>
21377 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21378<nav class="top-nav">
21379 <a class="brand" href="/">
21380 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21381 <span class="brand-title">OxideSLOC</span>
21382 </a>
21383</nav>
21384<main class="page">
21385 <div class="card">
21386 <h1>Sign In</h1>
21387 <p class="subtitle">Enter the API key printed when the server started.</p>
21388 {% if has_error %}
21389 <div class="error">Incorrect API key — please try again.</div>
21390 {% endif %}
21391 <form method="POST" action="/auth/login">
21392 <input type="hidden" name="next" value="{{ next_url|e }}">
21393 <label for="key">API Key</label>
21394 <input id="key" type="password" name="key" autocomplete="current-password"
21395 placeholder="Paste your API key here" autofocus>
21396 <button type="submit" class="btn">Sign In</button>
21397 </form>
21398 <p class="hint">
21399 The API key was printed in the terminal when the server started.<br>
21400 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
21401 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
21402 </p>
21403 </div>
21404</main>
21405<script nonce="{{ csp_nonce }}">
21406(function() {
21407 (function randomizeWatermarks() {
21408 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21409 if (!wms.length) return;
21410 var placed = [];
21411 function tooClose(top, left) {
21412 for (var i = 0; i < placed.length; i++) {
21413 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21414 if (dt < 16 && dl < 12) return true;
21415 }
21416 return false;
21417 }
21418 function pick(leftBand) {
21419 for (var attempt = 0; attempt < 50; attempt++) {
21420 var top = Math.random() * 88 + 2;
21421 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21422 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21423 }
21424 var top = Math.random() * 88 + 2;
21425 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21426 placed.push([top, left]); return [top, left];
21427 }
21428 var half = Math.floor(wms.length / 2);
21429 wms.forEach(function (img, i) {
21430 var pos = pick(i < half);
21431 var size = Math.floor(Math.random() * 100 + 120);
21432 var rot = (Math.random() * 360).toFixed(1);
21433 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21434 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;
21435 });
21436 })();
21437 (function spawnCodeParticles() {
21438 var container = document.getElementById('code-particles');
21439 if (!container) return;
21440 var snippets = [
21441 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21442 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21443 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21444 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21445 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21446 ];
21447 var count = 38;
21448 for (var i = 0; i < count; i++) {
21449 (function(idx) {
21450 var el = document.createElement('span');
21451 el.className = 'code-particle';
21452 el.textContent = snippets[idx % snippets.length];
21453 var left = Math.random() * 94 + 2;
21454 var top = Math.random() * 88 + 6;
21455 var dur = (Math.random() * 10 + 9).toFixed(1);
21456 var delay = (Math.random() * 18).toFixed(1);
21457 var rot = (Math.random() * 26 - 13).toFixed(1);
21458 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21459 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
21460 container.appendChild(el);
21461 })(i);
21462 }
21463 })();
21464})();
21465</script>
21466</body>
21467</html>
21468"##,
21469 ext = "html"
21470)]
21471pub(crate) struct LoginTemplate {
21472 pub(crate) csp_nonce: String,
21473 pub(crate) has_error: bool,
21474 pub(crate) next_url: String,
21475 pub(crate) lockout_threshold: u32,
21476}
21477
21478#[derive(Template)]
21481#[template(
21482 source = r##"
21483<!doctype html>
21484<html lang="en">
21485<head>
21486 <meta charset="utf-8">
21487 <meta name="viewport" content="width=device-width, initial-scale=1">
21488 <title>OxideSLOC — REST API Reference</title>
21489 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21490 <style nonce="{{ csp_nonce }}">
21491 :root {
21492 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21493 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21494 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21495 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21496 --success:#16a34a;
21497 }
21498 body.dark-theme {
21499 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21500 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21501 }
21502 *{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;}
21503 .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);}
21504 .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;}
21505 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
21506 .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));}
21507 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21508 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21509 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
21510 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
21511 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21512 @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; } }
21513 .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;}
21514 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
21515 .nav-pill.active{background:rgba(255,255,255,0.22);}
21516 .nav-dropdown{position:relative;display:inline-flex;}
21517 .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;}
21518 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
21519 .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;}
21520 .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;}
21521 .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);}
21522 .nav-dropdown-menu a:last-child{border-bottom:none;}
21523 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
21524 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21525 .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;}
21526 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21527 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21528 .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;}
21529 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21530 .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);}
21531 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
21532 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
21533 .settings-modal-body{padding:14px 16px 16px;}
21534 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21535 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21536 .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;}
21537 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21538 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21539 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21540 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21541 .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;}
21542 .tz-select:focus{border-color:var(--oxide);}
21543 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21544 .page-header{margin-bottom:28px;}
21545 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
21546 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
21547 .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;}
21548 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
21549 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
21550 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
21551 .callout strong{font-weight:800;}
21552 .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;}
21553 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
21554 .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;}
21555 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
21556 .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;}
21557 body.dark-theme .base-url-value{color:var(--accent);}
21558 .section{margin-bottom:36px;}
21559 .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);}
21560 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
21561 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
21562 .ep-header:hover{background:var(--surface-2);}
21563 .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;}
21564 .method.get{background:#dcfce7;color:#166534;}
21565 .method.post{background:#dbeafe;color:#1e40af;}
21566 .method.delete{background:#fee2e2;color:#991b1b;}
21567 body.dark-theme .method.get{background:#14532d;color:#86efac;}
21568 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
21569 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
21570 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
21571 .ep-path .param{color:var(--oxide-2);}
21572 body.dark-theme .ep-path .param{color:var(--oxide);}
21573 .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;}
21574 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
21575 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
21576 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
21577 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
21578 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
21579 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
21580 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
21581 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
21582 .ep-card.open .chevron{transform:rotate(180deg);}
21583 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
21584 .ep-card.open .ep-body{display:block;}
21585 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
21586 .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;}
21587 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
21588 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
21589 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21590 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
21591 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);}
21592 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
21593 table.params tr:last-child td{border-bottom:none;}
21594 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
21595 .pt-type{color:var(--muted-2);font-size:12px;}
21596 .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;}
21597 .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;}
21598 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
21599 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
21600 details.schema{margin-bottom:14px;}
21601 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;}
21602 details.schema summary:hover{color:var(--text);}
21603 .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;}
21604 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21605 .curl-wrap{position:relative;}
21606 .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;}
21607 .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;}
21608 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
21609 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
21610 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
21611 .webhook-note a{color:var(--accent-2);text-decoration:none;}
21612 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21613 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21614 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21615 .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;}
21616 @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));}}
21617 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21618 .site-footer a{color:var(--muted);}
21619 </style>
21620</head>
21621<body>
21622 <div class="background-watermarks" aria-hidden="true">
21623 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21624 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21625 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21626 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21627 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21628 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21629 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21630 </div>
21631 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21632 <div class="top-nav">
21633 <div class="top-nav-inner">
21634 <a class="brand" href="/">
21635 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21636 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21637 </a>
21638 <div class="nav-right">
21639 <a class="nav-pill" href="/">Home</a>
21640 <div class="nav-dropdown">
21641 <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>
21642 <div class="nav-dropdown-menu">
21643 <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>
21644 </div>
21645 </div>
21646 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21647 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21648 <div class="nav-dropdown">
21649 <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>
21650 <div class="nav-dropdown-menu">
21651 <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>
21652 </div>
21653 </div>
21654 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21655 <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>
21656 </button>
21657 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21658 <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>
21659 <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>
21660 </button>
21661 </div>
21662 </div>
21663 </div>
21664
21665 <div class="page">
21666 <div class="page-header">
21667 <h1 class="page-title">REST API Reference</h1>
21668 <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>
21669 </div>
21670
21671 {% if has_api_key %}
21672 <div class="callout key-set">
21673 <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>
21674 <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>
21675 </div>
21676 {% else %}
21677 <div class="callout no-key">
21678 <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>
21679 <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>
21680 </div>
21681 {% endif %}
21682
21683 <div class="base-url-bar">
21684 <span class="base-url-label">Base URL</span>
21685 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21686 </div>
21687
21688 <!-- Health -->
21689 <div class="section">
21690 <h2 class="section-title">Health & Status</h2>
21691 <div class="ep-card">
21692 <div class="ep-header">
21693 <span class="method get">GET</span>
21694 <span class="ep-path">/healthz</span>
21695 <span class="auth-badge public">Public</span>
21696 <span class="ep-desc">Server liveness check</span>
21697 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21698 </div>
21699 <div class="ep-body">
21700 <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>
21701 <p class="params-heading">Response</p>
21702 <div class="schema-block">200 OK
21703Content-Type: text/plain
21704
21705ok</div>
21706 <p class="curl-heading">Example</p>
21707 <div class="curl-wrap">
21708 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21709 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21710 </div>
21711 </div>
21712 </div>
21713 </div>
21714
21715 <!-- Badges -->
21716 <div class="section">
21717 <h2 class="section-title">Badges</h2>
21718 <div class="ep-card">
21719 <div class="ep-header">
21720 <span class="method get">GET</span>
21721 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21722 <span class="auth-badge public">Public</span>
21723 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21724 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21725 </div>
21726 <div class="ep-body">
21727 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21728 <p class="params-heading">Path Parameters</p>
21729 <table class="params">
21730 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21731 <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>
21732 </table>
21733 <p class="curl-heading">Example</p>
21734 <div class="curl-wrap">
21735 <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>
21736 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21737 </div>
21738 </div>
21739 </div>
21740 </div>
21741
21742 <!-- Metrics -->
21743 <div class="section">
21744 <h2 class="section-title">Metrics</h2>
21745
21746 <div class="ep-card">
21747 <div class="ep-header">
21748 <span class="method get">GET</span>
21749 <span class="ep-path">/api/metrics/latest</span>
21750 <span class="auth-badge protected">Protected</span>
21751 <span class="ep-desc">Latest scan metrics (JSON)</span>
21752 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21753 </div>
21754 <div class="ep-body">
21755 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21756 <details class="schema"><summary>Response schema</summary>
21757<div class="schema-block">{
21758 "run_id": string, // UUID
21759 "timestamp": string, // ISO-8601 UTC
21760 "project": string, // scanned root path
21761 "summary": {
21762 "files_analyzed": number,
21763 "files_skipped": number,
21764 "code_lines": number,
21765 "comment_lines": number,
21766 "blank_lines": number,
21767 "total_physical_lines": number,
21768 "functions": number,
21769 "classes": number,
21770 "variables": number,
21771 "imports": number
21772 },
21773 "languages": [
21774 { "name": string, "files": number, "code_lines": number,
21775 "comment_lines": number, "blank_lines": number,
21776 "functions": number, "classes": number,
21777 "variables": number, "imports": number }
21778 ]
21779}</div></details>
21780 <p class="curl-heading">Example</p>
21781 <div class="curl-wrap">
21782 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21783 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21784 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21785 </div>
21786 </div>
21787 </div>
21788
21789 <div class="ep-card">
21790 <div class="ep-header">
21791 <span class="method get">GET</span>
21792 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21793 <span class="auth-badge protected">Protected</span>
21794 <span class="ep-desc">Metrics for a specific run</span>
21795 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21796 </div>
21797 <div class="ep-body">
21798 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21799 <p class="params-heading">Path Parameters</p>
21800 <table class="params">
21801 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21802 <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>
21803 </table>
21804 <p class="curl-heading">Example</p>
21805 <div class="curl-wrap">
21806 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21807 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
21808 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21809 </div>
21810 </div>
21811 </div>
21812
21813 <div class="ep-card">
21814 <div class="ep-header">
21815 <span class="method get">GET</span>
21816 <span class="ep-path">/api/metrics/history</span>
21817 <span class="auth-badge protected">Protected</span>
21818 <span class="ep-desc">Paginated scan history</span>
21819 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21820 </div>
21821 <div class="ep-body">
21822 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21823 <p class="params-heading">Query Parameters</p>
21824 <table class="params">
21825 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21826 <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>
21827 <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>
21828 </table>
21829 <details class="schema"><summary>Response schema</summary>
21830<div class="schema-block">[{
21831 "run_id": string,
21832 "timestamp": string, // ISO-8601 UTC
21833 "commit": string | null,
21834 "branch": string | null,
21835 "tags": string[],
21836 "code_lines": number,
21837 "comment_lines": number,
21838 "blank_lines": number,
21839 "physical_lines": number,
21840 "files_analyzed": number,
21841 "project_label": string,
21842 "html_url": string | null
21843}]</div></details>
21844 <p class="curl-heading">Example</p>
21845 <div class="curl-wrap">
21846 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21847 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21848 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21849 </div>
21850 </div>
21851 </div>
21852
21853 <div class="ep-card">
21854 <div class="ep-header">
21855 <span class="method get">GET</span>
21856 <span class="ep-path">/api/project-history</span>
21857 <span class="auth-badge protected">Protected</span>
21858 <span class="ep-desc">Project-level scan summary</span>
21859 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21860 </div>
21861 <div class="ep-body">
21862 <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>
21863 <p class="params-heading">Query Parameters</p>
21864 <table class="params">
21865 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21866 <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>
21867 </table>
21868 <details class="schema"><summary>Response schema</summary>
21869<div class="schema-block">{
21870 "scan_count": number,
21871 "last_scan_id": string | null,
21872 "last_scan_timestamp": string | null, // ISO-8601
21873 "last_scan_code_lines": number | null,
21874 "last_git_branch": string | null,
21875 "last_git_commit": string | null
21876}</div></details>
21877 <p class="curl-heading">Example</p>
21878 <div class="curl-wrap">
21879 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21880 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21881 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21882 </div>
21883 </div>
21884 </div>
21885
21886 <div class="ep-card">
21887 <div class="ep-header">
21888 <span class="method get">GET</span>
21889 <span class="ep-path">/api/metrics/submodules</span>
21890 <span class="auth-badge protected">Protected</span>
21891 <span class="ep-desc">List known git submodules across scans</span>
21892 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21893 </div>
21894 <div class="ep-body">
21895 <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>
21896 <p class="params-heading">Query Parameters</p>
21897 <table class="params">
21898 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21899 <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>
21900 </table>
21901 <details class="schema"><summary>Response schema</summary>
21902<div class="schema-block">[{
21903 "name": string, // submodule name
21904 "relative_path": string // path relative to the project root
21905}]</div></details>
21906 <p class="curl-heading">Example</p>
21907 <div class="curl-wrap">
21908 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21909 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21910 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21911 </div>
21912 </div>
21913 </div>
21914 </div>
21915
21916 <!-- Async Run Status -->
21917 <div class="section">
21918 <h2 class="section-title">Async Run Status</h2>
21919
21920 <div class="ep-card">
21921 <div class="ep-header">
21922 <span class="method get">GET</span>
21923 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21924 <span class="auth-badge protected">Protected</span>
21925 <span class="ep-desc">Poll scan completion</span>
21926 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21927 </div>
21928 <div class="ep-body">
21929 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21930 <details class="schema"><summary>Response schema</summary>
21931<div class="schema-block">// Running
21932{ "state": "running", "elapsed_secs": number }
21933
21934// Complete
21935{ "state": "complete", "run_id": string }
21936
21937// Failed
21938{ "state": "failed", "message": string }</div></details>
21939 <p class="curl-heading">Example</p>
21940 <div class="curl-wrap">
21941 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21942 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
21943 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21944 </div>
21945 </div>
21946 </div>
21947
21948 <div class="ep-card">
21949 <div class="ep-header">
21950 <span class="method get">GET</span>
21951 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21952 <span class="auth-badge protected">Protected</span>
21953 <span class="ep-desc">Poll PDF generation readiness</span>
21954 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21955 </div>
21956 <div class="ep-body">
21957 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21958 <details class="schema"><summary>Response schema</summary>
21959<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21960 <p class="curl-heading">Example</p>
21961 <div class="curl-wrap">
21962 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21963 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
21964 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21965 </div>
21966 </div>
21967 </div>
21968
21969 <div class="ep-card">
21970 <div class="ep-header">
21971 <span class="method post">POST</span>
21972 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21973 <span class="auth-badge protected">Protected</span>
21974 <span class="ep-desc">Cancel a running scan</span>
21975 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21976 </div>
21977 <div class="ep-body">
21978 <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>
21979 <p class="curl-heading">Example</p>
21980 <div class="curl-wrap">
21981 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21982 -H "Authorization: Bearer $SLOC_API_KEY" \
21983 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
21984 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21985 </div>
21986 </div>
21987 </div>
21988 </div>
21989
21990 <!-- Scan Profiles -->
21991 <div class="section">
21992 <h2 class="section-title">Scan Profiles</h2>
21993
21994 <div class="ep-card">
21995 <div class="ep-header">
21996 <span class="method get">GET</span>
21997 <span class="ep-path">/api/scan-profiles</span>
21998 <span class="auth-badge protected">Protected</span>
21999 <span class="ep-desc">List saved scan profiles</span>
22000 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22001 </div>
22002 <div class="ep-body">
22003 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
22004 <details class="schema"><summary>Response schema</summary>
22005<div class="schema-block">{
22006 "profiles": [{
22007 "id": string, // UUID
22008 "name": string,
22009 "created_at": string, // ISO-8601
22010 "params": object
22011 }]
22012}</div></details>
22013 <p class="curl-heading">Example</p>
22014 <div class="curl-wrap">
22015 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22016 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22017 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
22018 </div>
22019 </div>
22020 </div>
22021
22022 <div class="ep-card">
22023 <div class="ep-header">
22024 <span class="method post">POST</span>
22025 <span class="ep-path">/api/scan-profiles</span>
22026 <span class="auth-badge protected">Protected</span>
22027 <span class="ep-desc">Save a scan profile</span>
22028 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22029 </div>
22030 <div class="ep-body">
22031 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
22032 <p class="params-heading">Request Body (application/json)</p>
22033 <table class="params">
22034 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22035 <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>
22036 <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>
22037 </table>
22038 <details class="schema"><summary>Response schema</summary>
22039<div class="schema-block">{ "ok": true }</div></details>
22040 <p class="curl-heading">Example</p>
22041 <div class="curl-wrap">
22042 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
22043 -H "Authorization: Bearer $SLOC_API_KEY" \
22044 -H "Content-Type: application/json" \
22045 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
22046 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22047 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
22048 </div>
22049 </div>
22050 </div>
22051
22052 <div class="ep-card">
22053 <div class="ep-header">
22054 <span class="method delete">DELETE</span>
22055 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
22056 <span class="auth-badge protected">Protected</span>
22057 <span class="ep-desc">Delete a scan profile</span>
22058 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22059 </div>
22060 <div class="ep-body">
22061 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
22062 <p class="params-heading">Path Parameters</p>
22063 <table class="params">
22064 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22065 <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>
22066 </table>
22067 <details class="schema"><summary>Response schema</summary>
22068<div class="schema-block">{ "ok": true }</div></details>
22069 <p class="curl-heading">Example</p>
22070 <div class="curl-wrap">
22071 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
22072 -H "Authorization: Bearer $SLOC_API_KEY" \
22073 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
22074 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
22075 </div>
22076 </div>
22077 </div>
22078 </div>
22079
22080 <!-- Scheduled Scans -->
22081 <div class="section">
22082 <h2 class="section-title">Scheduled Scans</h2>
22083
22084 <div class="ep-card">
22085 <div class="ep-header">
22086 <span class="method get">GET</span>
22087 <span class="ep-path">/api/schedules</span>
22088 <span class="auth-badge protected">Protected</span>
22089 <span class="ep-desc">List configured schedules</span>
22090 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22091 </div>
22092 <div class="ep-body">
22093 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
22094 <p class="curl-heading">Example</p>
22095 <div class="curl-wrap">
22096 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22097 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22098 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
22099 </div>
22100 </div>
22101 </div>
22102
22103 <div class="ep-card">
22104 <div class="ep-header">
22105 <span class="method post">POST</span>
22106 <span class="ep-path">/api/schedules</span>
22107 <span class="auth-badge protected">Protected</span>
22108 <span class="ep-desc">Create a schedule</span>
22109 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22110 </div>
22111 <div class="ep-body">
22112 <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>
22113 <p class="curl-heading">Example</p>
22114 <div class="curl-wrap">
22115 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
22116 -H "Authorization: Bearer $SLOC_API_KEY" \
22117 -H "Content-Type: application/json" \
22118 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
22119 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22120 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
22121 </div>
22122 </div>
22123 </div>
22124
22125 <div class="ep-card">
22126 <div class="ep-header">
22127 <span class="method delete">DELETE</span>
22128 <span class="ep-path">/api/schedules</span>
22129 <span class="auth-badge protected">Protected</span>
22130 <span class="ep-desc">Delete a schedule</span>
22131 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22132 </div>
22133 <div class="ep-body">
22134 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
22135 <p class="curl-heading">Example</p>
22136 <div class="curl-wrap">
22137 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
22138 -H "Authorization: Bearer $SLOC_API_KEY" \
22139 -H "Content-Type: application/json" \
22140 -d '{"id":"<schedule_id>"}' \
22141 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22142 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
22143 </div>
22144 </div>
22145 </div>
22146 </div>
22147
22148 <!-- Git Browser -->
22149 <div class="section">
22150 <h2 class="section-title">Git Browser</h2>
22151
22152 <div class="ep-card">
22153 <div class="ep-header">
22154 <span class="method get">GET</span>
22155 <span class="ep-path">/api/git/refs</span>
22156 <span class="auth-badge protected">Protected</span>
22157 <span class="ep-desc">List git refs for a repository</span>
22158 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22159 </div>
22160 <div class="ep-body">
22161 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
22162 <p class="params-heading">Query Parameters</p>
22163 <table class="params">
22164 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22165 <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>
22166 </table>
22167 <p class="curl-heading">Example</p>
22168 <div class="curl-wrap">
22169 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22170 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
22171 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
22172 </div>
22173 </div>
22174 </div>
22175
22176 <div class="ep-card">
22177 <div class="ep-header">
22178 <span class="method get">GET</span>
22179 <span class="ep-path">/api/git/scan-ref</span>
22180 <span class="auth-badge protected">Protected</span>
22181 <span class="ep-desc">SLOC-scan a specific git ref</span>
22182 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22183 </div>
22184 <div class="ep-body">
22185 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
22186 <p class="params-heading">Query Parameters</p>
22187 <table class="params">
22188 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22189 <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>
22190 <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>
22191 </table>
22192 <p class="curl-heading">Example</p>
22193 <div class="curl-wrap">
22194 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22195 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
22196 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
22197 </div>
22198 </div>
22199 </div>
22200
22201 <div class="ep-card">
22202 <div class="ep-header">
22203 <span class="method get">GET</span>
22204 <span class="ep-path">/api/git/compare-refs</span>
22205 <span class="auth-badge protected">Protected</span>
22206 <span class="ep-desc">Compare SLOC across two git refs</span>
22207 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22208 </div>
22209 <div class="ep-body">
22210 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
22211 <p class="params-heading">Query Parameters</p>
22212 <table class="params">
22213 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22214 <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>
22215 <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>
22216 <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>
22217 </table>
22218 <p class="curl-heading">Example</p>
22219 <div class="curl-wrap">
22220 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22221 "<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>
22222 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
22223 </div>
22224 </div>
22225 </div>
22226 </div>
22227
22228 <!-- Webhooks -->
22229 <div class="section">
22230 <h2 class="section-title">Webhooks</h2>
22231 <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>
22232
22233 <div class="ep-card">
22234 <div class="ep-header">
22235 <span class="method post">POST</span>
22236 <span class="ep-path">/webhooks/github</span>
22237 <span class="auth-badge hmac">HMAC</span>
22238 <span class="ep-desc">GitHub push event receiver</span>
22239 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22240 </div>
22241 <div class="ep-body">
22242 <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>
22243 <p class="params-heading">Required Headers</p>
22244 <table class="params">
22245 <tr><th>Header</th><th>Value</th></tr>
22246 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
22247 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
22248 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22249 </table>
22250 </div>
22251 </div>
22252
22253 <div class="ep-card">
22254 <div class="ep-header">
22255 <span class="method post">POST</span>
22256 <span class="ep-path">/webhooks/gitlab</span>
22257 <span class="auth-badge hmac">HMAC</span>
22258 <span class="ep-desc">GitLab push event receiver</span>
22259 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22260 </div>
22261 <div class="ep-body">
22262 <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>
22263 <p class="params-heading">Required Headers</p>
22264 <table class="params">
22265 <tr><th>Header</th><th>Value</th></tr>
22266 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
22267 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
22268 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22269 </table>
22270 </div>
22271 </div>
22272
22273 <div class="ep-card">
22274 <div class="ep-header">
22275 <span class="method post">POST</span>
22276 <span class="ep-path">/webhooks/bitbucket</span>
22277 <span class="auth-badge hmac">HMAC</span>
22278 <span class="ep-desc">Bitbucket push event receiver</span>
22279 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22280 </div>
22281 <div class="ep-body">
22282 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
22283 <p class="params-heading">Required Headers</p>
22284 <table class="params">
22285 <tr><th>Header</th><th>Value</th></tr>
22286 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
22287 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22288 </table>
22289 </div>
22290 </div>
22291 </div>
22292
22293 <!-- Config -->
22294 <div class="section">
22295 <h2 class="section-title">Config Import / Export</h2>
22296
22297 <div class="ep-card">
22298 <div class="ep-header">
22299 <span class="method get">GET</span>
22300 <span class="ep-path">/export-config</span>
22301 <span class="auth-badge protected">Protected</span>
22302 <span class="ep-desc">Export server configuration as JSON</span>
22303 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22304 </div>
22305 <div class="ep-body">
22306 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
22307 <p class="curl-heading">Example</p>
22308 <div class="curl-wrap">
22309 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22310 -o config.json \
22311 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
22312 <button class="curl-copy-btn" data-target="c-export">Copy</button>
22313 </div>
22314 </div>
22315 </div>
22316
22317 <div class="ep-card">
22318 <div class="ep-header">
22319 <span class="method post">POST</span>
22320 <span class="ep-path">/import-config</span>
22321 <span class="auth-badge protected">Protected</span>
22322 <span class="ep-desc">Import server configuration</span>
22323 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22324 </div>
22325 <div class="ep-body">
22326 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
22327 <p class="curl-heading">Example</p>
22328 <div class="curl-wrap">
22329 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
22330 -H "Authorization: Bearer $SLOC_API_KEY" \
22331 -H "Content-Type: application/json" \
22332 -d @config.json \
22333 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
22334 <button class="curl-copy-btn" data-target="c-import">Copy</button>
22335 </div>
22336 </div>
22337 </div>
22338 </div>
22339
22340 <!-- CI Ingest -->
22341 <div class="section">
22342 <h2 class="section-title">CI Ingest</h2>
22343
22344 <div class="ep-card">
22345 <div class="ep-header">
22346 <span class="method post">POST</span>
22347 <span class="ep-path">/api/ingest</span>
22348 <span class="auth-badge protected">Protected</span>
22349 <span class="ep-desc">Push a pre-computed scan result from CI</span>
22350 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22351 </div>
22352 <div class="ep-body">
22353 <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>
22354 <p class="params-heading">Query Parameters</p>
22355 <table class="params">
22356 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22357 <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>
22358 </table>
22359 <p class="params-heading">Request Body (application/json)</p>
22360 <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>
22361 <details class="schema"><summary>Response schema</summary>
22362<div class="schema-block">// 201 Created
22363{
22364 "run_id": string, // UUID of the ingested run
22365 "view_url": string // relative URL to the report page
22366}</div></details>
22367 <p class="curl-heading">Example</p>
22368 <div class="curl-wrap">
22369 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
22370 -H "Authorization: Bearer $SLOC_API_KEY" \
22371 -H "Content-Type: application/json" \
22372 -d @result.json \
22373 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
22374 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
22375 </div>
22376 </div>
22377 </div>
22378 </div>
22379
22380 <!-- Artifact Download -->
22381 <div class="section">
22382 <h2 class="section-title">Artifact Download</h2>
22383
22384 <div class="ep-card">
22385 <div class="ep-header">
22386 <span class="method get">GET</span>
22387 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
22388 <span class="auth-badge protected">Protected</span>
22389 <span class="ep-desc">Download or view a scan artifact</span>
22390 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22391 </div>
22392 <div class="ep-body">
22393 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
22394 <p class="params-heading">Path Parameters</p>
22395 <table class="params">
22396 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22397 <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>
22398 <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>
22399 </table>
22400 <p class="params-heading">Query Parameters</p>
22401 <table class="params">
22402 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22403 <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>
22404 </table>
22405 <p class="curl-heading">Example — download JSON result</p>
22406 <div class="curl-wrap">
22407 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22408 -o result.json \
22409 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
22410 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
22411 </div>
22412 </div>
22413 </div>
22414 </div>
22415
22416 <!-- Embed Widget -->
22417 <div class="section">
22418 <h2 class="section-title">Embed Widget</h2>
22419
22420 <div class="ep-card">
22421 <div class="ep-header">
22422 <span class="method get">GET</span>
22423 <span class="ep-path">/embed/summary</span>
22424 <span class="auth-badge protected">Protected</span>
22425 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
22426 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22427 </div>
22428 <div class="ep-body">
22429 <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>
22430 <p class="params-heading">Query Parameters</p>
22431 <table class="params">
22432 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22433 <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>
22434 <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>
22435 </table>
22436 <p class="curl-heading">Example</p>
22437 <div class="curl-wrap">
22438 <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"
22439 width="460" height="260" style="border:none"></iframe></pre>
22440 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
22441 </div>
22442 </div>
22443 </div>
22444 </div>
22445
22446 <!-- Confluence Integration -->
22447 <div class="section">
22448 <h2 class="section-title">Confluence Integration</h2>
22449
22450 <div class="ep-card">
22451 <div class="ep-header">
22452 <span class="method get">GET</span>
22453 <span class="ep-path">/api/confluence/config</span>
22454 <span class="auth-badge protected">Protected</span>
22455 <span class="ep-desc">Get current Confluence configuration</span>
22456 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22457 </div>
22458 <div class="ep-body">
22459 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
22460 <details class="schema"><summary>Response schema</summary>
22461<div class="schema-block">{
22462 "configured": boolean,
22463 "tier": "cloud" | "server",
22464 "base_url": string,
22465 "username": string,
22466 "api_token_set": boolean,
22467 "space_key": string,
22468 "parent_page_id": string | null,
22469 "schedule_auto_post": { "<schedule_id>": boolean }
22470}</div></details>
22471 <p class="curl-heading">Example</p>
22472 <div class="curl-wrap">
22473 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22474 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22475 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
22476 </div>
22477 </div>
22478 </div>
22479
22480 <div class="ep-card">
22481 <div class="ep-header">
22482 <span class="method post">POST</span>
22483 <span class="ep-path">/api/confluence/config</span>
22484 <span class="auth-badge protected">Protected</span>
22485 <span class="ep-desc">Save Confluence configuration</span>
22486 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22487 </div>
22488 <div class="ep-body">
22489 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
22490 <p class="params-heading">Request Body (application/json)</p>
22491 <table class="params">
22492 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22493 <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>
22494 <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>
22495 <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>
22496 <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>
22497 <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>
22498 <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>
22499 <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>
22500 </table>
22501 <details class="schema"><summary>Response schema</summary>
22502<div class="schema-block">{ "ok": true }</div></details>
22503 <p class="curl-heading">Example</p>
22504 <div class="curl-wrap">
22505 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
22506 -H "Authorization: Bearer $SLOC_API_KEY" \
22507 -H "Content-Type: application/json" \
22508 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
22509 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22510 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
22511 </div>
22512 </div>
22513 </div>
22514
22515 <div class="ep-card">
22516 <div class="ep-header">
22517 <span class="method post">POST</span>
22518 <span class="ep-path">/api/confluence/test</span>
22519 <span class="auth-badge protected">Protected</span>
22520 <span class="ep-desc">Test Confluence connection</span>
22521 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22522 </div>
22523 <div class="ep-body">
22524 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
22525 <details class="schema"><summary>Response schema</summary>
22526<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
22527 <p class="curl-heading">Example</p>
22528 <div class="curl-wrap">
22529 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
22530 -H "Authorization: Bearer $SLOC_API_KEY" \
22531 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
22532 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
22533 </div>
22534 </div>
22535 </div>
22536
22537 <div class="ep-card">
22538 <div class="ep-header">
22539 <span class="method post">POST</span>
22540 <span class="ep-path">/api/confluence/post</span>
22541 <span class="auth-badge protected">Protected</span>
22542 <span class="ep-desc">Publish a scan report to Confluence</span>
22543 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22544 </div>
22545 <div class="ep-body">
22546 <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>
22547 <p class="params-heading">Request Body (application/json)</p>
22548 <table class="params">
22549 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22550 <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>
22551 <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>
22552 <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>
22553 </table>
22554 <details class="schema"><summary>Response schema</summary>
22555<div class="schema-block">// 200 OK
22556{ "ok": true, "page_id": string }
22557
22558// 400 / 502 on error
22559{ "ok": false, "error": string }</div></details>
22560 <p class="curl-heading">Example</p>
22561 <div class="curl-wrap">
22562 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
22563 -H "Authorization: Bearer $SLOC_API_KEY" \
22564 -H "Content-Type: application/json" \
22565 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
22566 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
22567 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
22568 </div>
22569 </div>
22570 </div>
22571
22572 <div class="ep-card">
22573 <div class="ep-header">
22574 <span class="method get">GET</span>
22575 <span class="ep-path">/api/confluence/wiki-markup</span>
22576 <span class="auth-badge protected">Protected</span>
22577 <span class="ep-desc">Get Confluence wiki markup for a run</span>
22578 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22579 </div>
22580 <div class="ep-body">
22581 <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>
22582 <p class="params-heading">Query Parameters</p>
22583 <table class="params">
22584 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22585 <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>
22586 </table>
22587 <p class="curl-heading">Example</p>
22588 <div class="curl-wrap">
22589 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22590 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
22591 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
22592 </div>
22593 </div>
22594 </div>
22595 </div>
22596
22597 <!-- Authentication -->
22598 <div class="section">
22599 <h2 class="section-title">Authentication</h2>
22600 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
22601
22602 <div class="ep-card">
22603 <div class="ep-header">
22604 <span class="method get">GET</span>
22605 <span class="ep-path">/auth/login</span>
22606 <span class="auth-badge public">Public</span>
22607 <span class="ep-desc">Login page</span>
22608 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22609 </div>
22610 <div class="ep-body">
22611 <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>
22612 <p class="params-heading">Query Parameters</p>
22613 <table class="params">
22614 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22615 <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>
22616 <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>
22617 </table>
22618 </div>
22619 </div>
22620
22621 <div class="ep-card">
22622 <div class="ep-header">
22623 <span class="method post">POST</span>
22624 <span class="ep-path">/auth/login</span>
22625 <span class="auth-badge public">Public</span>
22626 <span class="ep-desc">Submit credentials and get a session cookie</span>
22627 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22628 </div>
22629 <div class="ep-body">
22630 <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>
22631 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22632 <table class="params">
22633 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22634 <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>
22635 <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>
22636 </table>
22637 <p class="curl-heading">Example</p>
22638 <div class="curl-wrap">
22639 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22640 -d "key=$SLOC_API_KEY&next=/" \
22641 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22642 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22643 </div>
22644 </div>
22645 </div>
22646 </div>
22647
22648 <!-- Coverage Suggestion -->
22649 <div class="section">
22650 <h2 class="section-title">Coverage Suggestion</h2>
22651
22652 <div class="ep-card">
22653 <div class="ep-header">
22654 <span class="method get">GET</span>
22655 <span class="ep-path">/api/suggest-coverage</span>
22656 <span class="auth-badge protected">Protected</span>
22657 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22658 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22659 </div>
22660 <div class="ep-body">
22661 <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>
22662 <p class="params-heading">Query Parameters</p>
22663 <table class="params">
22664 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22665 <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>
22666 </table>
22667 <details class="schema"><summary>Response schema</summary>
22668<div class="schema-block">{
22669 "found": string | null, // absolute path to the coverage file, if detected
22670 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22671 "hint": string | null // shell command to generate coverage if not found
22672}</div></details>
22673 <p class="curl-heading">Example</p>
22674 <div class="curl-wrap">
22675 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22676 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22677 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22678 </div>
22679 </div>
22680 </div>
22681 </div>
22682
22683 </div>
22684
22685 <footer class="site-footer">
22686 local code analysis - metrics, history and reports
22687 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22688 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22689 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22690 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22691 · <a href="/api-docs" rel="noopener">REST API</a>
22692 </footer>
22693
22694 <script nonce="{{ csp_nonce }}">
22695 (function () {
22696 var base = window.location.origin;
22697 document.getElementById('base-url').textContent = base;
22698 document.querySelectorAll('.base-url-slot').forEach(function (el) {
22699 el.textContent = base;
22700 });
22701
22702 document.querySelectorAll('.ep-header').forEach(function (hdr) {
22703 hdr.addEventListener('click', function () {
22704 hdr.closest('.ep-card').classList.toggle('open');
22705 });
22706 });
22707
22708 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22709 btn.addEventListener('click', function () {
22710 var targetId = btn.dataset.target;
22711 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22712 if (!pre) return;
22713 navigator.clipboard.writeText(pre.textContent).then(function () {
22714 btn.textContent = 'Copied!';
22715 btn.classList.add('copied');
22716 setTimeout(function () {
22717 btn.textContent = 'Copy';
22718 btn.classList.remove('copied');
22719 }, 2000);
22720 });
22721 });
22722 });
22723
22724 var storageKey = 'oxide-sloc-theme';
22725 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22726 var themeBtn = document.getElementById('theme-toggle');
22727 if (themeBtn) {
22728 themeBtn.addEventListener('click', function () {
22729 var dark = document.body.classList.toggle('dark-theme');
22730 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22731 });
22732 }
22733 (function() {
22734 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'}];
22735 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);});}
22736 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22737 var btn=document.getElementById('settings-btn');if(!btn)return;
22738 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22739 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>';
22740 document.body.appendChild(m);
22741 var g=document.getElementById('scheme-grid');
22742 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);});
22743 var cl=document.getElementById('settings-close');
22744 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);
22745 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');});
22746 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22747 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22748 })();
22749 (function randomizeWatermarks() {
22750 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22751 if (!wms.length) return;
22752 var placed = [];
22753 function tooClose(top, left) {
22754 for (var i = 0; i < placed.length; i++) {
22755 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22756 if (dt < 16 && dl < 12) return true;
22757 }
22758 return false;
22759 }
22760 function pick(leftBand) {
22761 for (var attempt = 0; attempt < 50; attempt++) {
22762 var top = Math.random() * 88 + 2;
22763 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22764 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22765 }
22766 var top = Math.random() * 88 + 2;
22767 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22768 placed.push([top, left]); return [top, left];
22769 }
22770 var half = Math.floor(wms.length / 2);
22771 wms.forEach(function (img, i) {
22772 var pos = pick(i < half);
22773 var size = Math.floor(Math.random() * 100 + 120);
22774 var rot = (Math.random() * 360).toFixed(1);
22775 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22776 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;
22777 });
22778 })();
22779 (function spawnCodeParticles() {
22780 var container = document.getElementById('code-particles');
22781 if (!container) return;
22782 var snippets = [
22783 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22784 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22785 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22786 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22787 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22788 ];
22789 var count = 38;
22790 for (var i = 0; i < count; i++) {
22791 (function(idx) {
22792 var el = document.createElement('span');
22793 el.className = 'code-particle';
22794 el.textContent = snippets[idx % snippets.length];
22795 var left = Math.random() * 94 + 2;
22796 var top = Math.random() * 88 + 6;
22797 var dur = (Math.random() * 10 + 9).toFixed(1);
22798 var delay = (Math.random() * 18).toFixed(1);
22799 var rot = (Math.random() * 26 - 13).toFixed(1);
22800 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22801 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22802 container.appendChild(el);
22803 })(i);
22804 }
22805 })();
22806 }());
22807 </script>
22808</body>
22809</html>
22810"##,
22811 ext = "html"
22812)]
22813struct ApiDocsTemplate {
22814 has_api_key: bool,
22815 csp_nonce: String,
22816 version: &'static str,
22817}