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 style_summary: None,
10042 }
10043}
10044
10045pub(crate) fn sanitize_project_label(raw: &str) -> String {
10046 let candidate = Path::new(raw)
10047 .file_name()
10048 .and_then(|name| name.to_str())
10049 .unwrap_or("project");
10050
10051 let mut value = String::with_capacity(candidate.len());
10052 for ch in candidate.chars() {
10053 if ch.is_ascii_alphanumeric() {
10054 value.push(ch.to_ascii_lowercase());
10055 } else {
10056 value.push('-');
10057 }
10058 }
10059
10060 let compact = value.trim_matches('-').to_string();
10061 if compact.is_empty() {
10062 "project".to_string()
10063 } else {
10064 compact
10065 }
10066}
10067
10068fn strip_unc_prefix(path: PathBuf) -> PathBuf {
10071 let s = path.to_string_lossy();
10072 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10073 return PathBuf::from(format!(r"\\{rest}"));
10074 }
10075 if let Some(rest) = s.strip_prefix(r"\\?\") {
10076 return PathBuf::from(rest);
10077 }
10078 path
10079}
10080
10081fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
10084 let base = if let Some(rest) = remote.strip_prefix("git@") {
10085 let (host, path) = rest.split_once(':')?;
10086 format!("https://{}/{}", host, path.trim_end_matches(".git"))
10087 } else if remote.starts_with("https://") || remote.starts_with("http://") {
10088 remote
10089 .trim_end_matches('/')
10090 .trim_end_matches(".git")
10091 .to_owned()
10092 } else {
10093 return None;
10094 };
10095 let base = base.trim_end_matches('/');
10096 if base.contains("gitlab.com") || base.contains("gitlab.") {
10098 Some(format!("{}/-/commit/{}", base, sha))
10099 } else if base.contains("bitbucket.org") {
10100 Some(format!("{}/commits/{}", base, sha))
10101 } else {
10102 Some(format!("{}/commit/{}", base, sha))
10103 }
10104}
10105
10106fn display_path(path: &Path) -> String {
10107 let s = path.to_string_lossy();
10108 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10113 return format!(r"\\{rest}");
10114 }
10115 if let Some(rest) = s.strip_prefix(r"\\?\") {
10116 return rest.to_owned();
10117 }
10118 s.into_owned()
10119}
10120
10121fn sanitize_path_str(s: &str) -> String {
10122 if let Some(rest) = s.strip_prefix("//?/UNC/") {
10126 return format!("//{rest}");
10127 }
10128 if let Some(rest) = s.strip_prefix("//?/") {
10129 return rest.to_owned();
10130 }
10131 display_path(Path::new(s))
10132}
10133
10134fn workspace_root() -> PathBuf {
10135 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
10137 let p = PathBuf::from(root);
10138 if p.is_dir() {
10139 return p;
10140 }
10141 }
10142
10143 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
10146}
10147
10148fn make_git_label(repo: &str, ref_name: &str) -> String {
10150 if repo.is_empty() || ref_name.is_empty() {
10151 return String::new();
10152 }
10153 let base = repo
10154 .trim_end_matches('/')
10155 .trim_end_matches(".git")
10156 .rsplit('/')
10157 .next()
10158 .unwrap_or("repo");
10159 let ref_safe: String = ref_name
10160 .chars()
10161 .map(|c| {
10162 if c.is_alphanumeric() || c == '-' || c == '.' {
10163 c
10164 } else {
10165 '_'
10166 }
10167 })
10168 .collect();
10169 format!("{base}_at_{ref_safe}_sloc")
10170}
10171
10172fn desktop_dir() -> PathBuf {
10174 if let Ok(profile) = std::env::var("USERPROFILE") {
10175 let p = PathBuf::from(profile).join("Desktop");
10176 if p.exists() {
10177 return p;
10178 }
10179 }
10180 if let Ok(home) = std::env::var("HOME") {
10181 let p = PathBuf::from(home).join("Desktop");
10182 if p.exists() {
10183 return p;
10184 }
10185 }
10186 workspace_root().join("out").join("web")
10187}
10188
10189fn resolve_input_path(raw: &str) -> PathBuf {
10190 let trimmed = raw.trim();
10191 if trimmed.is_empty() {
10192 return workspace_root().join("samples").join("basic");
10193 }
10194
10195 let candidate = PathBuf::from(trimmed);
10196 let resolved = if candidate.is_absolute() {
10197 candidate
10198 } else {
10199 let rooted = workspace_root().join(&candidate);
10200 if rooted.exists() {
10201 rooted
10202 } else {
10203 workspace_root().join(candidate)
10204 }
10205 };
10206
10207 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
10210 PathBuf::from(display_path(&canonical))
10211}
10212
10213fn dir_size_bytes(path: &Path) -> u64 {
10214 let mut total = 0u64;
10215 if let Ok(rd) = fs::read_dir(path) {
10216 for entry in rd.filter_map(Result::ok) {
10217 let p = entry.path();
10218 if p.is_file() {
10219 if let Ok(meta) = p.metadata() {
10220 total += meta.len();
10221 }
10222 } else if p.is_dir() {
10223 total += dir_size_bytes(&p);
10224 }
10225 }
10226 }
10227 total
10228}
10229
10230#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
10232 if bytes >= 1_073_741_824 {
10233 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
10234 } else if bytes >= 1_048_576 {
10235 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
10236 } else if bytes >= 1_024 {
10237 format!("{:.0} KB", bytes as f64 / 1_024.0)
10238 } else {
10239 format!("{bytes} B")
10240 }
10241}
10242
10243fn render_submodule_chips(
10244 root: &Path,
10245 submodules: &[(String, std::path::PathBuf)],
10246 out: &mut String,
10247) {
10248 use std::fmt::Write as _;
10249 let count = submodules.len();
10250 out.push_str(r#"<div class="submodule-preview-strip">"#);
10251 write!(
10252 out,
10253 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>"#,
10254 if count == 1 { "" } else { "s" }
10255 )
10256 .ok();
10257 out.push_str(r#"<div class="submodule-preview-chips">"#);
10258 for (sub_name, sub_rel_path) in submodules {
10259 let sub_abs = root.join(sub_rel_path);
10260 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
10261 let mut sub_stats = PreviewStats::default();
10262 let mut sub_rows: Vec<PreviewRow> = Vec::new();
10263 let mut sub_langs: Vec<&'static str> = Vec::new();
10264 let mut sub_budget = PreviewBudget {
10265 shown: 0,
10266 max_entries: 2000,
10267 max_depth: 9,
10268 };
10269 let mut sub_next_id = 1usize;
10270 let _ = collect_preview_rows(
10271 &sub_abs,
10272 &sub_abs,
10273 0,
10274 None,
10275 &mut sub_next_id,
10276 &mut sub_budget,
10277 &mut sub_stats,
10278 &mut sub_rows,
10279 &mut sub_langs,
10280 &[],
10281 &[],
10282 );
10283 let stats_json = format!(
10284 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
10285 sub_stats.directories,
10286 sub_stats.files,
10287 sub_stats.supported,
10288 sub_stats.skipped,
10289 sub_stats.unsupported
10290 );
10291 write!(
10292 out,
10293 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>"#,
10294 escape_html(sub_name),
10295 escape_html(&sub_rel_path.to_string_lossy()),
10296 escape_html(&sub_size),
10297 escape_html(&stats_json),
10298 escape_html(sub_name),
10299 escape_html(&sub_size),
10300 )
10301 .ok();
10302 }
10303 out.push_str(
10304 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
10305 );
10306 out.push_str(r"</div>");
10307}
10308
10309fn render_language_pills_row(languages: &[&str], out: &mut String) {
10310 use std::fmt::Write as _;
10311 if languages.is_empty() {
10312 out.push_str(
10313 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
10314 );
10315 return;
10316 }
10317 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10318 for language in languages {
10319 if let Some(icon) = language_icon_file(language) {
10320 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();
10321 } else if let Some(svg) = language_inline_svg(language) {
10322 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();
10323 } else {
10324 write!(
10325 out,
10326 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10327 escape_html(&language.to_ascii_lowercase()),
10328 escape_html(language)
10329 )
10330 .ok();
10331 }
10332 }
10333}
10334
10335#[allow(clippy::too_many_lines)]
10336fn build_preview_html(
10337 root: &Path,
10338 include_patterns: &[String],
10339 exclude_patterns: &[String],
10340) -> Result<String> {
10341 if !root.exists() {
10342 return Ok(format!(
10343 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10344 escape_html(&display_path(root))
10345 ));
10346 }
10347
10348 let _selected = display_path(root);
10349 let mut stats = PreviewStats::default();
10350 let mut rows = Vec::new();
10351 let mut languages = Vec::new();
10352 let mut budget = PreviewBudget {
10353 shown: 0,
10354 max_entries: 600,
10355 max_depth: 9,
10356 };
10357 let mut next_row_id = 1usize;
10358
10359 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10360 || root.to_string_lossy().into_owned(),
10361 std::string::ToString::to_string,
10362 );
10363 let root_modified = root
10364 .metadata()
10365 .ok()
10366 .and_then(|meta| meta.modified().ok())
10367 .map_or_else(|| "-".to_string(), format_system_time);
10368
10369 rows.push(PreviewRow {
10370 row_id: 0,
10371 parent_row_id: None,
10372 depth: 0,
10373 name: format!("{root_name}/"),
10374 kind: PreviewKind::Dir,
10375 is_dir: true,
10376 language: None,
10377 modified: root_modified,
10378 type_label: "Directory".to_string(),
10379 });
10380 collect_preview_rows(
10381 root,
10382 root,
10383 0,
10384 Some(0),
10385 &mut next_row_id,
10386 &mut budget,
10387 &mut stats,
10388 &mut rows,
10389 &mut languages,
10390 include_patterns,
10391 exclude_patterns,
10392 )?;
10393
10394 let root_size = format_dir_size(dir_size_bytes(root));
10395
10396 let mut out = String::new();
10397 write!(
10398 out,
10399 r#"<div class="explorer-wrap" data-project-size="{}">"#,
10400 escape_html(&root_size)
10401 )
10402 .ok();
10403 out.push_str(r#"<div class="explorer-toolbar compact">"#);
10404 out.push_str(r#"<div class="explorer-title-group">"#);
10405 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10406 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10407 out.push_str(r"</div></div>");
10408
10409 out.push_str(r#"<div class="scope-stats">"#);
10410 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();
10411 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();
10412 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();
10413 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();
10414 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();
10415 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>"#);
10416 out.push_str(r"</div>");
10417
10418 let submodules = sloc_core::detect_submodules(root);
10419 if !submodules.is_empty() {
10420 render_submodule_chips(root, &submodules, &mut out);
10421 }
10422
10423 out.push_str(r#"<div class="scope-info-row">"#);
10424 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10425 render_language_pills_row(&languages, &mut out);
10426 out.push_str(r"</div></div>");
10427 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>"#);
10428 out.push_str(r"</div>");
10429
10430 out.push_str(r#"<div class="file-explorer-shell">"#);
10431 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>"#);
10432 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>"#);
10433 out.push_str(r#"<div class="file-explorer-tree">"#);
10434 for row in rows {
10435 let status_label = row.kind.label();
10436 let lang_attr = row.language.unwrap_or("");
10437 let toggle_html = if row.is_dir {
10438 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10439 .to_string()
10440 } else {
10441 r#"<span class="tree-bullet">•</span>"#.to_string()
10442 };
10443 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();
10444 }
10445 if budget.shown >= budget.max_entries {
10446 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>"#);
10447 }
10448 out.push_str(r"</div></div></div>");
10449
10450 Ok(out)
10451}
10452
10453#[derive(Default)]
10454struct PreviewStats {
10455 directories: usize,
10456 files: usize,
10457 supported: usize,
10458 skipped: usize,
10459 unsupported: usize,
10460}
10461
10462struct PreviewRow {
10463 row_id: usize,
10464 parent_row_id: Option<usize>,
10465 depth: usize,
10466 name: String,
10467 kind: PreviewKind,
10468 is_dir: bool,
10469 language: Option<&'static str>,
10470 modified: String,
10471 type_label: String,
10472}
10473
10474#[derive(Copy, Clone)]
10475enum PreviewKind {
10476 Dir,
10477 Supported,
10478 Skipped,
10479 Unsupported,
10480}
10481
10482impl PreviewKind {
10483 const fn filter_key(self) -> &'static str {
10484 match self {
10485 Self::Dir => "dir",
10486 Self::Supported => "supported",
10487 Self::Skipped => "skipped",
10488 Self::Unsupported => "unsupported",
10489 }
10490 }
10491
10492 const fn label(self) -> &'static str {
10493 match self {
10494 Self::Dir => "dir",
10495 Self::Supported => "supported",
10496 Self::Skipped => "skipped by policy",
10497 Self::Unsupported => "unsupported",
10498 }
10499 }
10500
10501 const fn badge_class(self) -> &'static str {
10502 match self {
10503 Self::Dir => "badge badge-dir",
10504 Self::Supported => "badge badge-scan",
10505 Self::Skipped => "badge badge-skip",
10506 Self::Unsupported => "badge badge-unsupported",
10507 }
10508 }
10509
10510 const fn node_class(self) -> &'static str {
10511 match self {
10512 Self::Dir => "tree-node-dir",
10513 Self::Supported => "tree-node-supported",
10514 Self::Skipped => "tree-node-skipped",
10515 Self::Unsupported => "tree-node-unsupported",
10516 }
10517 }
10518}
10519
10520struct PreviewBudget {
10521 shown: usize,
10522 max_entries: usize,
10523 max_depth: usize,
10524}
10525
10526#[allow(clippy::too_many_arguments)]
10529fn handle_preview_dir_entry(
10530 root: &Path,
10531 path: &Path,
10532 name: &str,
10533 modified: String,
10534 depth: usize,
10535 parent_row_id: Option<usize>,
10536 row_id: usize,
10537 next_row_id: &mut usize,
10538 budget: &mut PreviewBudget,
10539 stats: &mut PreviewStats,
10540 rows: &mut Vec<PreviewRow>,
10541 languages: &mut Vec<&'static str>,
10542 include_patterns: &[String],
10543 exclude_patterns: &[String],
10544) -> Result<()> {
10545 let relative = preview_relative_path(root, path);
10546 if should_skip_preview_directory(&relative, exclude_patterns) {
10547 return Ok(());
10548 }
10549 stats.directories += 1;
10550 rows.push(PreviewRow {
10551 row_id,
10552 parent_row_id,
10553 depth: depth + 1,
10554 name: format!("{name}/"),
10555 kind: PreviewKind::Dir,
10556 is_dir: true,
10557 language: None,
10558 modified,
10559 type_label: "Directory".to_string(),
10560 });
10561 budget.shown += 1;
10562 if !matches!(name, ".git" | "node_modules" | "target") {
10563 collect_preview_rows(
10564 root,
10565 path,
10566 depth + 1,
10567 Some(row_id),
10568 next_row_id,
10569 budget,
10570 stats,
10571 rows,
10572 languages,
10573 include_patterns,
10574 exclude_patterns,
10575 )?;
10576 }
10577 Ok(())
10578}
10579
10580#[allow(clippy::too_many_arguments)]
10582fn handle_preview_file_entry(
10583 root: &Path,
10584 path: &Path,
10585 name: &str,
10586 modified: String,
10587 depth: usize,
10588 parent_row_id: Option<usize>,
10589 row_id: usize,
10590 budget: &mut PreviewBudget,
10591 stats: &mut PreviewStats,
10592 rows: &mut Vec<PreviewRow>,
10593 languages: &mut Vec<&'static str>,
10594 include_patterns: &[String],
10595 exclude_patterns: &[String],
10596) {
10597 let relative = preview_relative_path(root, path);
10598 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10599 return;
10600 }
10601 stats.files += 1;
10602 let kind = classify_preview_file(name);
10603 match kind {
10604 PreviewKind::Supported => stats.supported += 1,
10605 PreviewKind::Skipped => stats.skipped += 1,
10606 PreviewKind::Unsupported => stats.unsupported += 1,
10607 PreviewKind::Dir => {}
10608 }
10609 let language = detect_language_name(name);
10610 if let Some(lang) = language {
10611 if !languages.contains(&lang) {
10612 languages.push(lang);
10613 }
10614 }
10615 rows.push(PreviewRow {
10616 row_id,
10617 parent_row_id,
10618 depth: depth + 1,
10619 name: name.to_owned(),
10620 kind,
10621 is_dir: false,
10622 language,
10623 modified,
10624 type_label: preview_type_label(name, language, kind),
10625 });
10626 budget.shown += 1;
10627}
10628
10629#[allow(clippy::too_many_arguments)]
10630#[allow(clippy::too_many_lines)]
10631fn collect_preview_rows(
10632 root: &Path,
10633 dir: &Path,
10634 depth: usize,
10635 parent_row_id: Option<usize>,
10636 next_row_id: &mut usize,
10637 budget: &mut PreviewBudget,
10638 stats: &mut PreviewStats,
10639 rows: &mut Vec<PreviewRow>,
10640 languages: &mut Vec<&'static str>,
10641 include_patterns: &[String],
10642 exclude_patterns: &[String],
10643) -> Result<()> {
10644 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10645 return Ok(());
10646 }
10647
10648 let mut entries = fs::read_dir(dir)
10649 .with_context(|| format!("failed to read directory {}", dir.display()))?
10650 .filter_map(std::result::Result::ok)
10651 .collect::<Vec<_>>();
10652 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10653
10654 for entry in entries {
10655 if budget.shown >= budget.max_entries {
10656 break;
10657 }
10658
10659 let path = entry.path();
10660 let name = entry.file_name().to_string_lossy().into_owned();
10661 let Ok(metadata) = entry.metadata() else {
10662 continue;
10663 };
10664 let row_id = *next_row_id;
10665 *next_row_id += 1;
10666 let modified = metadata
10667 .modified()
10668 .ok()
10669 .map_or_else(|| "-".to_string(), format_system_time);
10670
10671 if metadata.is_dir() {
10672 handle_preview_dir_entry(
10673 root,
10674 &path,
10675 &name,
10676 modified,
10677 depth,
10678 parent_row_id,
10679 row_id,
10680 next_row_id,
10681 budget,
10682 stats,
10683 rows,
10684 languages,
10685 include_patterns,
10686 exclude_patterns,
10687 )?;
10688 continue;
10689 }
10690
10691 if metadata.is_file() {
10692 handle_preview_file_entry(
10693 root,
10694 &path,
10695 &name,
10696 modified,
10697 depth,
10698 parent_row_id,
10699 row_id,
10700 budget,
10701 stats,
10702 rows,
10703 languages,
10704 include_patterns,
10705 exclude_patterns,
10706 );
10707 }
10708 }
10709
10710 Ok(())
10711}
10712
10713fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10714 if let Some(language) = language {
10715 return format!("{language} source");
10716 }
10717 let lower = name.to_ascii_lowercase();
10718 let ext = Path::new(&lower)
10719 .extension()
10720 .and_then(|e| e.to_str())
10721 .unwrap_or("");
10722 match kind {
10723 PreviewKind::Skipped => {
10724 if lower.ends_with(".min.js") {
10725 "Minified asset".to_string()
10726 } else if [
10727 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10728 ]
10729 .contains(&ext)
10730 {
10731 "Binary or archive".to_string()
10732 } else {
10733 "Skipped file".to_string()
10734 }
10735 }
10736 PreviewKind::Unsupported => {
10737 if ext.is_empty() {
10738 "Unsupported file".to_string()
10739 } else {
10740 format!("{} file", ext.to_ascii_uppercase())
10741 }
10742 }
10743 PreviewKind::Supported => "Supported source".to_string(),
10744 PreviewKind::Dir => "Directory".to_string(),
10745 }
10746}
10747
10748fn format_system_time(time: SystemTime) -> String {
10749 #[allow(clippy::cast_possible_wrap)]
10750 let secs = match time.duration_since(UNIX_EPOCH) {
10751 Ok(duration) => duration.as_secs() as i64,
10752 Err(_) => return "-".to_string(),
10753 };
10754 let days = secs.div_euclid(86_400);
10755 let secs_of_day = secs.rem_euclid(86_400);
10756 let (year, month, day) = civil_from_days(days);
10757 let hour = secs_of_day / 3_600;
10758 let minute = (secs_of_day % 3_600) / 60;
10759 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10760}
10761
10762#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10763fn civil_from_days(days: i64) -> (i32, u32, u32) {
10764 let z = days + 719_468;
10765 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10766 let doe = z - era * 146_097;
10767 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10768 let y = yoe + era * 400;
10769 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10770 let mp = (5 * doy + 2) / 153;
10771 let d = doy - (153 * mp + 2) / 5 + 1;
10772 let m = mp + if mp < 10 { 3 } else { -9 };
10773 let year = y + i64::from(m <= 2);
10774 (year as i32, m as u32, d as u32)
10775}
10776
10777#[allow(clippy::case_sensitive_file_extension_comparisons)]
10780fn detect_language_name(name: &str) -> Option<&'static str> {
10781 let lower = name.to_ascii_lowercase();
10782 if lower.ends_with(".c") || lower.ends_with(".h") {
10783 Some("C")
10784 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10785 .iter()
10786 .any(|s| lower.ends_with(s))
10787 {
10788 Some("C++")
10789 } else if lower.ends_with(".cs") {
10790 Some("C#")
10791 } else if lower.ends_with(".py") {
10792 Some("Python")
10793 } else if lower.ends_with(".sh") {
10794 Some("Shell")
10795 } else if [".ps1", ".psm1", ".psd1"]
10796 .iter()
10797 .any(|s| lower.ends_with(s))
10798 {
10799 Some("PowerShell")
10800 } else {
10801 None
10802 }
10803}
10804
10805fn language_icon_file(language: &str) -> Option<&'static str> {
10806 match language {
10807 "C" => Some("c.png"),
10808 "C++" => Some("cpp.png"),
10809 "C#" => Some("c-sharp.png"),
10810 "Python" => Some("python.png"),
10811 "Shell" => Some("shell.png"),
10812 "PowerShell" => Some("powershell.png"),
10813 "JavaScript" => Some("java-script.png"),
10814 "HTML" => Some("html-5.png"),
10815 "Java" => Some("java.png"),
10816 "Visual Basic" => Some("visual-basic.png"),
10817 "Assembly" => Some("asm.png"),
10818 "Go" => Some("go.png"),
10819 "R" => Some("r.png"),
10820 "XML" => Some("xml.png"),
10821 "Groovy" => Some("groovy.png"),
10822 "Dockerfile" => Some("docker.png"),
10823 "Makefile" => Some("makefile.svg"),
10824 "Perl" => Some("perl.svg"),
10825 _ => None,
10826 }
10827}
10828
10829fn language_inline_svg(language: &str) -> Option<&'static str> {
10834 match language {
10835 "Rust" => Some(
10836 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>"##,
10837 ),
10838 "TypeScript" => Some(
10839 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>"##,
10840 ),
10841 _ => None,
10842 }
10843}
10844
10845#[allow(clippy::case_sensitive_file_extension_comparisons)]
10848fn classify_preview_file(name: &str) -> PreviewKind {
10849 let lower = name.to_ascii_lowercase();
10850
10851 let scannable = [
10852 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10853 ".psm1", ".psd1",
10854 ]
10855 .iter()
10856 .any(|suffix| lower.ends_with(suffix));
10857
10858 if scannable {
10859 PreviewKind::Supported
10860 } else if lower.ends_with(".min.js")
10861 || lower.ends_with(".lock")
10862 || lower.ends_with(".png")
10863 || lower.ends_with(".jpg")
10864 || lower.ends_with(".jpeg")
10865 || lower.ends_with(".gif")
10866 || lower.ends_with(".zip")
10867 || lower.ends_with(".pdf")
10868 || lower.ends_with(".pyc")
10869 || lower.ends_with(".xz")
10870 || lower.ends_with(".tar")
10871 || lower.ends_with(".gz")
10872 {
10873 PreviewKind::Skipped
10874 } else {
10875 PreviewKind::Unsupported
10876 }
10877}
10878
10879fn preview_relative_path(root: &Path, path: &Path) -> String {
10880 path.strip_prefix(root)
10881 .ok()
10882 .unwrap_or(path)
10883 .to_string_lossy()
10884 .replace('\\', "/")
10885 .trim_matches('/')
10886 .to_string()
10887}
10888
10889fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10890 if relative.is_empty() {
10891 return false;
10892 }
10893
10894 exclude_patterns.iter().any(|pattern| {
10895 wildcard_match(pattern, relative)
10896 || wildcard_match(pattern, &format!("{relative}/"))
10897 || wildcard_match(pattern, &format!("{relative}/placeholder"))
10898 })
10899}
10900
10901fn should_include_preview_file(
10902 relative: &str,
10903 include_patterns: &[String],
10904 exclude_patterns: &[String],
10905) -> bool {
10906 if relative.is_empty() {
10907 return true;
10908 }
10909
10910 let included = include_patterns.is_empty()
10911 || include_patterns
10912 .iter()
10913 .any(|pattern| wildcard_match(pattern, relative));
10914 let excluded = exclude_patterns
10915 .iter()
10916 .any(|pattern| wildcard_match(pattern, relative));
10917
10918 included && !excluded
10919}
10920
10921fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10922 let pattern = pattern.trim().replace('\\', "/");
10923 let candidate = candidate.trim().replace('\\', "/");
10924 let p = pattern.as_bytes();
10925 let c = candidate.as_bytes();
10926 let mut pi = 0usize;
10927 let mut ci = 0usize;
10928 let mut star: Option<usize> = None;
10929 let mut star_match = 0usize;
10930
10931 while ci < c.len() {
10932 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10933 pi += 1;
10934 ci += 1;
10935 } else if pi < p.len() && p[pi] == b'*' {
10936 while pi < p.len() && p[pi] == b'*' {
10937 pi += 1;
10938 }
10939 star = Some(pi);
10940 star_match = ci;
10941 } else if let Some(star_pi) = star {
10942 star_match += 1;
10943 ci = star_match;
10944 pi = star_pi;
10945 } else {
10946 return false;
10947 }
10948 }
10949
10950 while pi < p.len() && p[pi] == b'*' {
10951 pi += 1;
10952 }
10953
10954 pi == p.len()
10955}
10956
10957fn escape_html(value: &str) -> String {
10958 value
10959 .replace('&', "&")
10960 .replace('<', "<")
10961 .replace('>', ">")
10962 .replace('"', """)
10963 .replace('\'', "'")
10964}
10965
10966#[derive(Clone)]
10967struct SubmoduleRow {
10968 name: String,
10969 relative_path: String,
10970 files_analyzed: u64,
10971 code_lines: u64,
10972 comment_lines: u64,
10973 blank_lines: u64,
10974 total_physical_lines: u64,
10975 html_url: Option<String>,
10976}
10977
10978#[derive(Template)]
10979#[template(
10980 source = r##"
10981<!doctype html>
10982<html lang="en">
10983<head>
10984 <meta charset="utf-8">
10985 <title>OxideSLOC | tmp-sloc</title>
10986 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10987 <style nonce="{{ csp_nonce }}">
10988 :root {
10989 --bg: #efe9e2;
10990 --surface: #fcfaf7;
10991 --surface-2: #f7f0e8;
10992 --surface-3: #efe3d5;
10993 --line: #dfcfbf;
10994 --line-strong: #cfb29c;
10995 --text: #2f241c;
10996 --muted: #6f6257;
10997 --muted-2: #917f71;
10998 --nav: #b85d33;
10999 --nav-2: #7a371b;
11000 --accent: #2563eb;
11001 --accent-2: #1d4ed8;
11002 --oxide: #b85d33;
11003 --oxide-2: #8f4220;
11004 --success-bg: #eaf9ee;
11005 --success-text: #1c8746;
11006 --warn-bg: #fff2d8;
11007 --warn-text: #926000;
11008 --danger-bg: #fdeaea;
11009 --danger-text: #b33b3b;
11010 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
11011 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
11012 --radius: 14px;
11013 }
11014
11015 body.dark-theme {
11016 --bg: #1b1511;
11017 --surface: #261c17;
11018 --surface-2: #2d221d;
11019 --surface-3: #372922;
11020 --line: #524238;
11021 --line-strong: #6c5649;
11022 --text: #f5ece6;
11023 --muted: #c7b7aa;
11024 --muted-2: #aa9485;
11025 --nav: #b85d33;
11026 --nav-2: #7a371b;
11027 --accent: #6f9bff;
11028 --accent-2: #4a78ee;
11029 --oxide: #d37a4c;
11030 --oxide-2: #b35428;
11031 --success-bg: #163927;
11032 --success-text: #8fe2a8;
11033 --warn-bg: #3c2d11;
11034 --warn-text: #f3cb75;
11035 --danger-bg: #3d1f1f;
11036 --danger-text: #ff9f9f;
11037 --shadow: 0 14px 28px rgba(0,0,0,0.28);
11038 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
11039 }
11040
11041 * { box-sizing: border-box; }
11042 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); }
11043 html { overflow-y: scroll; }
11044 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
11045 .top-nav, .page, .loading { position: relative; z-index: 2; }
11046 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
11047 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
11048 .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); }
11049 .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; }
11050 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
11051 .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)); }
11052 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
11053 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
11054 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
11055 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
11056 .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; }
11057 .nav-project-pill.visible { display:inline-flex; }
11058 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
11059 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
11060 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
11061 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
11062 @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; } }
11063 .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; }
11064 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
11065 .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; }
11066 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
11067 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
11068 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
11069 .theme-toggle .icon-sun { display:none; }
11070 body.dark-theme .theme-toggle .icon-sun { display:block; }
11071 body.dark-theme .theme-toggle .icon-moon { display:none; }
11072 .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;}
11073 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
11074 .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);}
11075 .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;}
11076 .settings-close:hover{color:var(--text);background:var(--surface-2);}
11077 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
11078 .settings-modal-body{padding:14px 16px 16px;}
11079 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
11080 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
11081 .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;}
11082 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
11083 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
11084 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
11085 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
11086 .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;}
11087 .tz-select:focus{border-color:var(--oxide);}
11088 .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; }
11089 .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;}
11090 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
11091 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
11092 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
11093 .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; }
11094 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
11095 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
11096 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
11097 .wb-stats-header { padding: 10px 24px 0; }
11098 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
11099 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
11100 .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; }
11101 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11102 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11103 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11104 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
11105 .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; }
11106 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
11107 .ws-stat-analyzers { position: relative; }
11108 .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; }
11109 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
11110 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
11111 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
11112 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
11113 .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; }
11114 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
11115 .ws-divider { display: none; }
11116 .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%; }
11117 .ws-path-link:hover { color:var(--oxide); }
11118 body.dark-theme .ws-path-link { color:var(--oxide); }
11119 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
11120 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11121 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
11122 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11123 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
11124 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
11125 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
11126 .ws-mini-box-lg { flex:2 1 0; }
11127 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
11128 .ws-mini-box-br { flex:1.5 1 0; }
11129 .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); }
11130 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
11131 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
11132 #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; }
11133 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
11134 .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; }
11135 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
11136 .git-source-banner strong { font-weight:800; color:var(--text); }
11137 .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; }
11138 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
11139 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
11140 .git-source-banner a:hover { text-decoration:underline; }
11141 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
11142 .path-scope-sep { background:var(--line); margin:4px 14px; }
11143 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
11144 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
11145 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
11146 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
11147 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
11148 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
11149 .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; }
11150 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11151 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11152 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11153 .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; }
11154 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
11155 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
11156 [data-wb-tip] { cursor:help; }
11157 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
11158 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
11159 .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; }
11160 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
11161 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
11162 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
11163 .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; }
11164 .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); }
11165 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
11166 .side-info-card { padding: 18px; }
11167 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
11168 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
11169 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
11170 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
11171 .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); }
11172 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
11173 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11174 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
11175 .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; }
11176 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
11177 .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; }
11178 .side-stack::-webkit-scrollbar { display: none; }
11179 .step-nav { padding: 20px 16px; }
11180 .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); }
11181 .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; }
11182 .step-button:hover { background: var(--surface-2); }
11183 .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); }
11184 .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; }
11185 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11186 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
11187 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
11188 .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); }
11189 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
11190 .step-nav-sum-row:last-child { border-bottom:none; }
11191 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
11192 .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; }
11193 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
11194 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
11195 .quick-scan-section { padding: 10px 4px 14px; }
11196 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
11197 .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; }
11198 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
11199 .quick-scan-btn:active { transform:translateY(0); }
11200 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
11201 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
11202 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
11203 @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);} }
11204 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
11205 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
11206 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
11207 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
11208 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
11209 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
11210 .step-button.done .step-check { opacity:1; }
11211 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
11212 .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; }
11213 .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; }
11214 .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; }
11215 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
11216 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
11217 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
11218 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
11219 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
11220 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
11221 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
11222 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
11223 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
11224 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
11225 .card-body { padding: 22px; }
11226 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
11227 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
11228 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
11229 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
11230 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
11231 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
11232 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
11233 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
11234 .field { min-width:0; }
11235 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
11236 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; }
11237 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); }
11238 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
11239 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); }
11240 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
11241 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11242 .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; }
11243 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
11244 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
11245 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
11246 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
11247 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
11248 .input-group.compact { grid-template-columns: 1fr auto auto; }
11249 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
11250 .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)); }
11251 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
11252 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
11253 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
11254 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
11255 .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; }
11256 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
11257 .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; }
11258 .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); }
11259 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
11260 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
11261 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
11262 button.secondary { background: var(--surface); }
11263 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11264 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
11265 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
11266 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11267 .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); }
11268 .section + .wizard-actions { border-top: none; padding-top: 0; }
11269 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
11270 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11271 .field-help-grid.coupled-help { margin-top: 12px; }
11272 .field-help-grid.preset-grid { align-items: start; }
11273 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
11274 .preset-inline-row .field { margin: 0; }
11275 .preset-inline-row .explainer-card { margin: 0; }
11276 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
11277 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
11278 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
11279 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
11280 .preset-kv-row > :last-child { flex:1; min-width:0; }
11281 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
11282 .output-field-row .field { margin: 0; }
11283 .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; }
11284 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
11285 .step3-subtitle { margin-bottom: 10px; max-width: none; }
11286 .counting-intro { margin-bottom: 8px; max-width: none; }
11287 .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; }
11288 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
11289 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
11290 .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; }
11291 .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; }
11292 .section-spacer-top { margin-top: 28px; }
11293 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
11294 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
11295 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
11296 .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); }
11297 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
11298 .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; }
11299 .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; }
11300 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
11301 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11302 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
11303 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
11304 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
11305 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
11306 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
11307 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
11308 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
11309 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
11310 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
11311 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
11312 .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); }
11313 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
11314 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
11315 .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; }
11316 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
11317 .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; }
11318 .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; }
11319 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11320 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11321 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11322 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11323 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11324 .advanced-rule-description strong { color: var(--text); }
11325 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11326 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11327 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11328 .review-link:hover { text-decoration: underline; }
11329 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11330 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11331 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11332 .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; }
11333 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11334 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11335 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11336 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11337 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11338 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11339 .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; }
11340 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11341 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11342 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11343 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11344 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11345 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11346 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11347 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11348 .review-card ul { padding-left: 18px; margin: 0; }
11349 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11350 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11351 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11352 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11353 .review-card { min-height: 0; }
11354 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11355 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11356 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11357 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11358 .lang-overflow-chip { position:relative; cursor:default; }
11359 .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; }
11360 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11361 .git-inline-row { align-items:start; }
11362 .mixed-line-card { display:flex; flex-direction:column; }
11363 .preset-inline-row .toggle-card { justify-content: center; }
11364 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11365 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11366 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11367 .explorer-title { font-size: 18px; font-weight: 850; }
11368 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11369 .explorer-subtitle.wide { max-width: none; }
11370 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11371 .better-spacing { align-items:flex-start; justify-content:flex-end; }
11372 .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; }
11373 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11374 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11375 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11376 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11377 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11378 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11379 .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; }
11380 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11381 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11382 .scope-stat-button.supported { background: var(--success-bg); }
11383 .scope-stat-button.skipped { background: var(--warn-bg); }
11384 .scope-stat-button.unsupported { background: var(--danger-bg); }
11385 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11386 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11387 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11388 [data-tooltip] { position: relative; }
11389 [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); }
11390 [data-tooltip]:hover::after { display: block; }
11391 .scope-stat-button[data-tooltip] { cursor: pointer; }
11392 .badge[data-tooltip] { cursor: help; }
11393 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11394 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11395 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11396 .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; }
11397 .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; }
11398 code { display:inline-block; margin-top:0; padding:2px 7px; }
11399 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11400 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11401 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11402 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11403 .language-pill.muted-pill { color: var(--muted); }
11404 button.language-pill { appearance:none; cursor:pointer; }
11405 .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); }
11406 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11407 .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; }
11408 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11409 .file-explorer-search-row { margin-left: auto; }
11410 .explorer-filter-select { min-width: 170px; width: 170px; }
11411 .explorer-search { min-width: 300px; width: 300px; }
11412 .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); }
11413 .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; }
11414 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11415 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11416 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11417 .file-explorer-tree { max-height: 640px; overflow:auto; }
11418 .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); }
11419 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11420 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11421 .tree-row.hidden-by-filter { display:none !important; }
11422 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11423 .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; }
11424 .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; }
11425 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11426 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11427 .tree-node { display:inline-flex; align-items:center; min-width:0; }
11428 .tree-node-dir { color: var(--text); font-weight: 800; }
11429 .tree-node-supported { color: var(--success-text); }
11430 .tree-node-skipped { color: var(--warn-text); }
11431 .tree-node-unsupported { color: var(--danger-text); }
11432 .tree-node-more { color: var(--muted-2); font-style: italic; }
11433 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11434 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11435 .tree-status-cell { display:flex; justify-content:flex-start; }
11436 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11437 .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; }
11438 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11439 .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; }
11440 @keyframes prevSpin { to { transform:rotate(360deg); } }
11441 .preview-loading-text { flex:1; min-width:0; }
11442 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
11443 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
11444 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11445 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11446 .cov-scan-idle { display:none; }
11447 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11448 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11449 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11450 .cov-scan-title { font-weight:600; font-size:12.5px; }
11451 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11452 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11453 .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; }
11454 .cov-scan-use:hover { opacity:.75; }
11455 .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; }
11456 .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; }
11457 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11458 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11459 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11460 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11461 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11462 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11463 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11464 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11465 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11466 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11467 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11468 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11469 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11470 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11471 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11472 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11473 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11474 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11475 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11476 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11477 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11478 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11479 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11480 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11481 .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); }
11482 .loading.active { display:flex; }
11483 .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; }
11484 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11485 .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; }
11486 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11487 .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; }
11488 .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; }
11489 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11490 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11491 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11492 .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; }
11493 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11494 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11495 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11496 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11497 .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; }
11498 .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; }
11499 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11500 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11501 .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; }
11502 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11503 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11504 .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; }
11505 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11506 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11507 .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; }
11508 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11509 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11510 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11511 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11512 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11513 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11514 .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; }
11515 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11516 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11517 .hidden { display:none !important; }
11518 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11519 .site-footer a{color:var(--muted);}
11520 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11521 @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; } }
11522 .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;}
11523 @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));}}
11524 .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;}
11525 .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; }
11526 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11527 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11528 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11529 .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; }
11530 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11531 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11532 .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; }
11533 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11534 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11535 .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; }
11536 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11537 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11538 .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; }
11539 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11540 .info-icon-btn:hover { color:var(--text); }
11541 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); }
11542 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11543 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11544 .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;}
11545 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11546 .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;}
11547 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11548 </style>
11549</head>
11550<body>
11551 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" />
11566 </div>
11567 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11568 <div class="top-nav">
11569 <div class="top-nav-inner">
11570 <a class="brand" href="/">
11571 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11572 <div class="brand-copy">
11573 <div class="brand-title">OxideSLOC</div>
11574 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11575 </div>
11576 </a>
11577 <div class="nav-project-slot">
11578 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11579 <span class="nav-project-label">Project</span>
11580 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11581 </div>
11582 </div>
11583 <div class="nav-status">
11584 <a class="nav-pill" href="/">Home</a>
11585 <div class="nav-dropdown">
11586 <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>
11587 <div class="nav-dropdown-menu">
11588 <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>
11589 </div>
11590 </div>
11591 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11592 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11593 <div class="nav-dropdown">
11594 <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>
11595 <div class="nav-dropdown-menu">
11596 <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>
11597 </div>
11598 </div>
11599 <div class="server-status-wrap" id="server-status-wrap">
11600 <div class="nav-pill server-online-pill" id="server-status-pill">
11601 <span class="status-dot" id="status-dot"></span>
11602 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11603 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11604 </div>
11605 <div class="server-status-tip">
11606 {% if server_mode %}
11607 OxideSLOC is running in server mode — accessible on your LAN.
11608 {% else %}
11609 OxideSLOC is running locally — only accessible from this machine.
11610 {% endif %}
11611 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11612 </div>
11613 </div>
11614 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11615 <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>
11616 </button>
11617 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11618 <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>
11619 <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>
11620 </button>
11621 </div>
11622 </div>
11623 </div>
11624
11625 <div class="loading" id="loading">
11626 <div class="loading-card">
11627 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11628 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11629 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
11630 <div class="lc-path" id="lc-path"></div>
11631 <div class="lc-metrics" id="lc-metrics">
11632 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11633 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11634 <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>
11635 </div>
11636 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11637 <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>
11638 <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>
11639 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11640 <div class="lc-actions hidden" id="lc-actions">
11641 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11642 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11643 </div>
11644 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11645 <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>
11646 Cancel scan
11647 </button>
11648 </div>
11649 </div>
11650
11651 <div class="page">
11652 <div class="workbench-strip">
11653 <div class="workbench-box wb-stats">
11654 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11655 <span class="wb-stats-title">Analysis session</span>
11656 </div>
11657 <div class="ws-left">
11658 <div class="ws-stat ws-stat-analyzers">
11659 <span class="ws-label">Analyzers</span>
11660 <span class="ws-value">
11661 <span class="ws-badge">41 languages</span>
11662 </span>
11663 <div class="ws-lang-tooltip">
11664 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11665 <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>
11666 <div class="ws-lang-grid">
11667 <span class="ws-lang-item">Assembly</span>
11668 <span class="ws-lang-item">C</span>
11669 <span class="ws-lang-item">C++</span>
11670 <span class="ws-lang-item">C#</span>
11671 <span class="ws-lang-item">Clojure</span>
11672 <span class="ws-lang-item">CSS</span>
11673 <span class="ws-lang-item">Dart</span>
11674 <span class="ws-lang-item">Dockerfile</span>
11675 <span class="ws-lang-item">Elixir</span>
11676 <span class="ws-lang-item">Erlang</span>
11677 <span class="ws-lang-item">F#</span>
11678 <span class="ws-lang-item">Go</span>
11679 <span class="ws-lang-item">Groovy</span>
11680 <span class="ws-lang-item">Haskell</span>
11681 <span class="ws-lang-item">HTML</span>
11682 <span class="ws-lang-item">Java</span>
11683 <span class="ws-lang-item">JavaScript</span>
11684 <span class="ws-lang-item">Julia</span>
11685 <span class="ws-lang-item">Kotlin</span>
11686 <span class="ws-lang-item">Lua</span>
11687 <span class="ws-lang-item">Makefile</span>
11688 <span class="ws-lang-item">Nim</span>
11689 <span class="ws-lang-item">Obj-C</span>
11690 <span class="ws-lang-item">OCaml</span>
11691 <span class="ws-lang-item">Perl</span>
11692 <span class="ws-lang-item">PHP</span>
11693 <span class="ws-lang-item">PowerShell</span>
11694 <span class="ws-lang-item">Python</span>
11695 <span class="ws-lang-item">R</span>
11696 <span class="ws-lang-item">Ruby</span>
11697 <span class="ws-lang-item">Rust</span>
11698 <span class="ws-lang-item">Scala</span>
11699 <span class="ws-lang-item">SCSS</span>
11700 <span class="ws-lang-item">Shell</span>
11701 <span class="ws-lang-item">SQL</span>
11702 <span class="ws-lang-item">Svelte</span>
11703 <span class="ws-lang-item">Swift</span>
11704 <span class="ws-lang-item">TypeScript</span>
11705 <span class="ws-lang-item">Vue</span>
11706 <span class="ws-lang-item">XML</span>
11707 <span class="ws-lang-item">Zig</span>
11708 </div>
11709 </div>
11710 </div>
11711 <div class="ws-divider"></div>
11712 <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>
11713 <div class="ws-divider"></div>
11714 <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.">
11715 <span class="ws-label">Output</span>
11716 <span class="ws-value">
11717 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11718 <span id="ws-output-root">project/sloc</span>
11719 </button>
11720 </span>
11721 </div>
11722 </div>
11723 </div>
11724 <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.">
11725 <div class="ws-history-label">Scan history</div>
11726 <div class="ws-history-inner">
11727 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11728 <div class="ws-mini-label">Scans</div>
11729 <div class="ws-mini-value" id="ws-scan-count">—</div>
11730 </div>
11731 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11732 <div class="ws-mini-label">Last Scan</div>
11733 <div class="ws-mini-value" id="ws-last-scan">—</div>
11734 </div>
11735 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11736 <div class="ws-mini-label">Branch</div>
11737 <div class="ws-mini-value" id="ws-branch">—</div>
11738 </div>
11739 </div>
11740 </div>
11741 </div>
11742
11743 <div class="layout">
11744 <aside class="side-stack">
11745 <section class="step-nav">
11746 <h3>Guided scan setup</h3>
11747 <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>
11748 <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>
11749 <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>
11750 <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>
11751
11752 <div class="step-steps-divider"></div>
11753
11754 <div class="step-nav-info" id="step-nav-info">
11755 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11756 <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>
11757 </div>
11758
11759 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11760 <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>
11761 <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>
11762 <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>
11763 </div>
11764
11765 <div class="quick-scan-divider"></div>
11766 <div class="quick-scan-section">
11767 <div class="quick-scan-label">No customization needed?</div>
11768 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11769 <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>
11770 Quick Scan
11771 </button>
11772 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11773 </div>
11774
11775 <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>
11776 </section>
11777
11778 </aside>
11779
11780 <section class="card">
11781 <div class="card-header">
11782 <div class="card-title-row">
11783 <div>
11784 <h1 class="card-title">Guided scan configuration</h1>
11785 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11786 </div>
11787 <div class="wizard-progress" aria-label="Scan setup progress">
11788 <div class="wizard-progress-top">
11789 <span class="wizard-progress-label">Setup progress</span>
11790 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11791 </div>
11792 <div class="wizard-progress-track">
11793 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11794 </div>
11795 </div>
11796 </div>
11797 </div>
11798 <div class="card-body">
11799 <form method="post" action="/analyze" id="analyze-form">
11800 <div class="wizard-step active" data-step="1">
11801 <div class="section">
11802 <div class="section-kicker">Step 1</div>
11803 <h2>Select project and preview scope</h2>
11804 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11805 <div class="field">
11806 <label for="path">Project path</label>
11807 {% if !git_repo.is_empty() %}
11808 <div class="git-source-banner">
11809 <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>
11810 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11811 <a href="/git-browser">← Back to Git Browser</a>
11812 </div>
11813 {% endif %}
11814 <div class="path-scope-grid">
11815 {% if !git_repo.is_empty() %}
11816 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11817 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11818 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11819 {% else %}
11820 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11821 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11822 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11823 {% endif %}
11824 <div class="path-scope-sep"></div>
11825 <div class="scope-legend-row">
11826 <span class="scope-legend-label">Scope legend:</span>
11827 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11828 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11829 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11830 </div>
11831 </div>
11832 {% if git_repo.is_empty() %}
11833 {% if server_mode %}
11834 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11835 ℹ️ Files are compressed and streamed — no fixed size limit.
11836 </div>
11837 {% endif %}
11838 <div class="path-info-row">
11839 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11840 <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>
11841 <span id="project-size-text">Project size: —</span>
11842 </button>
11843 </div>
11844 {% else %}
11845 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11846 {% endif %}
11847 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11848 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11849 </div>
11850
11851 <div class="scope-preview-divider" aria-hidden="true"></div>
11852
11853 <div id="preview-panel">
11854 <div class="preview-error">Loading preview...</div>
11855 </div>
11856 </div>
11857
11858 <div class="section" style="margin-top:14px;">
11859 <div class="preset-inline-row git-inline-row">
11860 <div class="toggle-card" style="margin:0;">
11861 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11862 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11863 <label class="checkbox">
11864 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11865 <div>
11866 <span>Detect and separate git submodules</span>
11867 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11868 </div>
11869 </label>
11870 </div>
11871 <div class="explainer-card prominent" style="margin:0;">
11872 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11873 <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>
11874 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11875 path = libs/core
11876 url = https://github.com/org/core.git
11877
11878[submodule "libs/ui"]
11879 path = libs/ui
11880 url = https://github.com/org/ui.git</div>
11881 </div>
11882 </div>
11883 </div>
11884
11885 <div class="section">
11886 <div class="field-grid">
11887 <div class="field">
11888 <label for="include_globs">Include globs</label>
11889 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
11890 <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>
11891 </div>
11892 <div class="field">
11893 <label for="exclude_globs">Exclude globs</label>
11894 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
11895 <div id="quick-exclude-chips" class="quick-excl-row">
11896 <span class="quick-excl-label">Quick add:</span>
11897 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11898 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11899 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11900 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11901 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11902 <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>
11903 </div>
11904 <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>
11905 </div>
11906 </div>
11907 <div class="glob-guidance-grid">
11908 <div class="glob-guidance-card">
11909 <strong>How to read them</strong>
11910 <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>
11911 </div>
11912 <div class="glob-guidance-card">
11913 <strong>Common include examples</strong>
11914 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11915 </div>
11916 <div class="glob-guidance-card">
11917 <strong>Common exclude examples</strong>
11918 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11919 </div>
11920 </div>
11921 </div>
11922
11923 <div class="section" style="margin-top:14px;">
11924 <div class="preset-inline-row git-inline-row">
11925 <div class="toggle-card" style="margin:0;">
11926 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11927 <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>
11928 <div class="field" style="margin:0;">
11929 <div class="input-group compact">
11930 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11931 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11932 </div>
11933 <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>
11934 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11935 </div>
11936 </div>
11937 <div class="explainer-card prominent" style="margin:0;">
11938 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11939 <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>
11940 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11941lcov --capture --directory . --output-file coverage/lcov.info
11942
11943# C / C++ — llvm-cov (LCOV)
11944llvm-profdata merge -sparse default.profraw -o default.profdata
11945llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11946
11947# C# — coverlet (Cobertura XML)
11948dotnet test --collect:"XPlat Code Coverage"
11949
11950# Python — pytest-cov (Cobertura XML)
11951pytest --cov --cov-report=xml
11952
11953# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11954./gradlew jacocoTestReport</div>
11955 </div>
11956 </div>
11957 </div>
11958
11959 <div class="wizard-actions">
11960 <div class="left"></div>
11961 <div class="right">
11962 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11963 </div>
11964 </div>
11965 </div>
11966
11967 <div class="wizard-step" data-step="2">
11968 <div class="section">
11969 <div class="section-kicker">Step 2</div>
11970 <h2>Choose counting behavior</h2>
11971 <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>
11972<div class="subsection-bar">Primary line classification</div>
11973 <div class="preset-kv-row">
11974 <div class="toggle-card mixed-line-card" style="margin:0;">
11975 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11976 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11977 <select id="mixed_line_policy" name="mixed_line_policy">
11978 <option value="code_only">Code only</option>
11979 <option value="code_and_comment">Code and comment</option>
11980 <option value="comment_only">Comment only</option>
11981 <option value="separate_mixed_category">Separate mixed category</option>
11982 </select>
11983 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11984 </div>
11985 <div class="explainer-card prominent" style="margin:0;">
11986 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11987 <div class="explainer-body" id="mixed-policy-description"></div>
11988 <div class="code-sample" id="mixed-policy-example"></div>
11989 </div>
11990 </div>
11991 </div>
11992
11993 <div class="subsection-bar">Additional scan rules</div>
11994 <div class="scan-rules-grid">
11995 <div class="preset-inline-row">
11996 <div class="toggle-card" style="margin:0;">
11997 <div class="field-help-title">Generated files</div>
11998 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11999 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12000 </div>
12001 <div class="explainer-card prominent" style="margin:0;">
12002 <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>
12003 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
12004# Files matching codegen patterns are excluded:
12005# *.generated.cs *.pb.go *.g.dart</div>
12006 </div>
12007 </div>
12008 <div class="preset-inline-row">
12009 <div class="toggle-card" style="margin:0;">
12010 <div class="field-help-title">Minified files</div>
12011 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
12012 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12013 </div>
12014 <div class="explainer-card prominent" style="margin:0;">
12015 <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>
12016 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
12017# Heuristic: very long lines + low whitespace ratio
12018# jquery.min.js bundle.min.css → skipped</div>
12019 </div>
12020 </div>
12021 <div class="preset-inline-row">
12022 <div class="toggle-card" style="margin:0;">
12023 <div class="field-help-title">Vendor directories</div>
12024 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
12025 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12026 </div>
12027 <div class="explainer-card prominent" style="margin:0;">
12028 <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>
12029 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
12030# Directories named vendor/ node_modules/ third_party/
12031# → entire subtree is excluded from totals</div>
12032 </div>
12033 </div>
12034 <div class="preset-inline-row">
12035 <div class="toggle-card" style="margin:0;">
12036 <div class="field-help-title">Lockfiles and manifests</div>
12037 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
12038 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
12039 </div>
12040 <div class="explainer-card prominent" style="margin:0;">
12041 <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>
12042 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
12043# Files like package-lock.json Cargo.lock yarn.lock
12044# → skipped unless this is enabled</div>
12045 </div>
12046 </div>
12047 <div class="preset-inline-row">
12048 <div class="toggle-card" style="margin:0;">
12049 <div class="field-help-title">Binary handling</div>
12050 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
12051 <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>
12052 </div>
12053 <div class="explainer-card prominent" style="margin:0;">
12054 <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>
12055 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
12056# Detected via long lines + low whitespace heuristic
12057# .png .exe .so → skipped silently</div>
12058 </div>
12059 </div>
12060 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
12061 <div class="toggle-card" style="margin:0;">
12062 <div class="field-help-title">Python docstrings</div>
12063 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
12064 <label class="checkbox">
12065 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
12066 <span>Count as comment-style lines</span>
12067 </label>
12068 </div>
12069 <div class="explainer-card prominent" style="margin:0;">
12070 <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>
12071 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
12072 </div>
12073 </div>
12074 </div>
12075 <div class="subsection-bar">IEEE 1045-1992 counting</div>
12076 <div class="scan-rules-grid">
12077 <div class="preset-inline-row">
12078 <div class="toggle-card" style="margin:0;">
12079 <div class="field-help-title">Continuation lines</div>
12080 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
12081 <select name="continuation_line_policy" id="continuation_line_policy">
12082 <option value="each_physical_line" selected>Each physical line (default)</option>
12083 <option value="collapse_to_logical">Collapse to logical line</option>
12084 </select>
12085 </div>
12086 <div class="explainer-card prominent" style="margin:0;">
12087 <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>
12088 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
12089 ((a) > (b) ? (a) : (b))
12090# each_physical_line → 2 SLOC
12091# collapse_to_logical → 1 SLOC</div>
12092 </div>
12093 </div>
12094 <div class="preset-inline-row">
12095 <div class="toggle-card" style="margin:0;">
12096 <div class="field-help-title">Block-comment blanks</div>
12097 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
12098 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
12099 <option value="count_as_comment" selected>Count as comment (default)</option>
12100 <option value="count_as_blank">Count as blank</option>
12101 </select>
12102 </div>
12103 <div class="explainer-card prominent" style="margin:0;">
12104 <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>
12105 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
12106 * Summary line
12107 * ← blank inside block comment
12108 * Detail line
12109 */
12110# count_as_comment → blank counts toward comments
12111# count_as_blank → blank counts toward blanks</div>
12112 </div>
12113 </div>
12114 <div class="preset-inline-row">
12115 <div class="toggle-card" style="margin:0;">
12116 <div class="field-help-title">Compiler directives</div>
12117 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
12118 <select name="count_compiler_directives" id="count_compiler_directives">
12119 <option value="enabled" selected>Include in code SLOC (default)</option>
12120 <option value="disabled">Exclude from code SLOC</option>
12121 </select>
12122 </div>
12123 <div class="explainer-card prominent" style="margin:0;">
12124 <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>
12125 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
12126#define BUF 256 ← compiler directive
12127int main() { … } ← code
12128# enabled → 3 code SLOC
12129# disabled → 1 code SLOC + 2 directive lines</div>
12130 </div>
12131 </div>
12132 </div>
12133
12134 <div class="always-tracked-tip">
12135 <div class="always-tracked-tip-icon">ℹ</div>
12136 <div class="always-tracked-tip-body">
12137 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
12138 <h4>Comment and blank-line basics & Lines on the boundary</h4>
12139 <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>
12140 </div>
12141 </div>
12142
12143 <div class="wizard-actions">
12144 <div class="left">
12145 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
12146 </div>
12147 <div class="right">
12148 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
12149 </div>
12150 </div>
12151 </div>
12152
12153 <div class="wizard-step" data-step="3">
12154 <div class="section">
12155 <div class="section-kicker">Step 3</div>
12156 <h2>Output and report identity</h2>
12157 <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>
12158 <div class="preset-kv-row">
12159 <div class="toggle-card" style="margin:0;">
12160 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
12161 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
12162 <select id="scan_preset">
12163 <option value="balanced">Balanced local scan</option>
12164 <option value="code_focused">Code focused</option>
12165 <option value="comment_audit">Comment audit</option>
12166 <option value="deep_review">Deep review</option>
12167 </select>
12168 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
12169 </div>
12170 <div class="explainer-card">
12171 <div class="field-help-title">Selected scan preset</div>
12172 <div class="explainer-body" id="scan-preset-description"></div>
12173 <div class="preset-summary-row" id="scan-preset-summary"></div>
12174 <div class="code-sample" id="scan-preset-example"></div>
12175 <div class="preset-note" id="scan-preset-note"></div>
12176 </div>
12177 </div>
12178 <hr class="step3-separator" />
12179 <div class="preset-kv-row">
12180 <div class="toggle-card" style="margin:0;">
12181 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
12182 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
12183 <select id="artifact_preset">
12184 <option value="review">Review bundle</option>
12185 <option value="full">Full bundle</option>
12186 <option value="html_only">HTML only</option>
12187 <option value="machine">Machine bundle</option>
12188 </select>
12189 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
12190 </div>
12191 <div class="explainer-card">
12192 <div class="field-help-title">Selected artifact preset</div>
12193 <div class="explainer-body" id="artifact-preset-description"></div>
12194 <div class="preset-summary-row" id="artifact-preset-summary"></div>
12195 <div class="code-sample" id="artifact-preset-example"></div>
12196 </div>
12197 </div>
12198 </div>
12199
12200 <div class="section section-spacer-top">
12201 <div class="output-field-row">
12202 <div class="field">
12203 <label for="output_dir">Output directory</label>
12204 {% if server_mode %}
12205 <div class="input-group compact">
12206 <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);" />
12207 </div>
12208 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
12209 {% else %}
12210 <div class="input-group compact">
12211 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
12212 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
12213 <button type="button" class="mini-button" id="use-default-output">Use default</button>
12214 </div>
12215 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
12216 {% endif %}
12217 </div>
12218 <div class="output-field-aside">
12219 <strong>Where reports land</strong>
12220 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.
12221 </div>
12222 </div>
12223 </div>
12224
12225 <div class="section section-spacer-top">
12226 <div class="output-field-row">
12227 <div class="field">
12228 <label for="report_title">Report title</label>
12229 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
12230 <div class="hint">Appears in HTML and PDF output headers.</div>
12231 </div>
12232 <div class="output-field-aside">
12233 <strong>Shown in exported artifacts</strong>
12234 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.
12235 </div>
12236 </div>
12237 </div>
12238
12239 <div class="section section-spacer-top">
12240 <div class="output-field-row">
12241 <div class="field">
12242 <label for="report_header_footer">Report header / footer</label>
12243 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
12244 <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>
12245 </div>
12246 <div class="output-field-aside">
12247 <strong>Page-level identification</strong>
12248 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.
12249 </div>
12250 </div>
12251 </div>
12252
12253 <div class="section">
12254 <div class="section-kicker">Artifacts</div>
12255 <div class="artifact-grid" style="margin-bottom:24px;">
12256 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
12257 <div class="marker">✓</div>
12258 <div class="artifact-icon">H</div>
12259 <h4>HTML report</h4>
12260 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
12261 <div class="artifact-tags">
12262 <span class="soft-chip">Best for visual review</span>
12263 <span class="soft-chip">Embeddable preview</span>
12264 </div>
12265 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
12266 </div>
12267 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
12268 <div class="marker">✓</div>
12269 <div class="artifact-icon">P</div>
12270 <h4>PDF export</h4>
12271 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
12272 <div class="artifact-tags">
12273 <span class="soft-chip">Portable snapshot</span>
12274 <span class="soft-chip">Good for handoff</span>
12275 </div>
12276 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
12277 </div>
12278 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
12279 <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>
12280 <div class="marker">✓</div>
12281 <div class="artifact-icon" style="color:var(--muted);">J</div>
12282 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
12283 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
12284 <div class="artifact-tags">
12285 <span class="soft-chip">Required for compare</span>
12286 <span class="soft-chip">Auto-enabled</span>
12287 </div>
12288 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
12289 </div>
12290 </div>
12291 <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>
12292 </div>
12293
12294 <div class="wizard-actions">
12295 <div class="left">
12296 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
12297 </div>
12298 <div class="right">
12299 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
12300 </div>
12301 </div>
12302 </div>
12303
12304 <div class="wizard-step" data-step="4">
12305 <div class="section">
12306 <div class="section-kicker">Step 4</div>
12307 <h2>Review selections and run</h2>
12308 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
12309 <div class="review-grid">
12310 <div class="review-card highlight">
12311 <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>
12312 <ul id="review-scan-summary"></ul>
12313 </div>
12314 <div class="review-card highlight">
12315 <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>
12316 <ul id="review-count-summary"></ul>
12317 </div>
12318 <div class="review-card">
12319 <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>
12320 <ul id="review-artifact-summary"></ul>
12321 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
12322 </div>
12323 <div class="review-card">
12324 <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>
12325 <ul id="review-preview-summary"></ul>
12326 </div>
12327 </div>
12328 </div>
12329
12330 <div class="wizard-actions">
12331 <div class="left">
12332 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12333 </div>
12334 <div class="right">
12335 <button type="submit" id="submit-button" class="primary">Run analysis</button>
12336 </div>
12337 </div>
12338 </div>
12339 {% if server_mode %}
12340 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12341 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12342 {% endif %}
12343 </form>
12344 </div>
12345 </section>
12346 </div>
12347 </div>
12348
12349 <script nonce="{{ csp_nonce }}">
12350 (function () {
12351 function startScanPhase() {
12352 var phaseEl = document.getElementById("scan-phase");
12353 if (!phaseEl) return;
12354 var phases = [
12355 "Discovering files...",
12356 "Decoding file encodings...",
12357 "Detecting languages...",
12358 "Analyzing source lines...",
12359 "Applying counting policies...",
12360 "Aggregating results...",
12361 "Rendering report..."
12362 ];
12363 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12364 var i = 0;
12365 function next() {
12366 phaseEl.style.opacity = "0";
12367 setTimeout(function () {
12368 phaseEl.textContent = phases[i];
12369 phaseEl.style.opacity = "0.85";
12370 var delay = durations[i] || 1800;
12371 i++;
12372 if (i < phases.length) { setTimeout(next, delay); }
12373 }, 200);
12374 }
12375 next();
12376 }
12377
12378 var form = document.getElementById("analyze-form");
12379 var loading = document.getElementById("loading");
12380 var submitButton = document.getElementById("submit-button");
12381 var pathInput = document.getElementById("path");
12382 var GIT_MODE = !!(pathInput && pathInput.readOnly);
12383 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12384 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12385 var outputDirInput = document.getElementById("output_dir");
12386 var reportTitleInput = document.getElementById("report_title");
12387 var previewPanel = document.getElementById("preview-panel");
12388 var refreshButton = document.getElementById("refresh-preview");
12389 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12390 var useSamplePath = document.getElementById("use-sample-path");
12391 var useDefaultOutput = document.getElementById("use-default-output");
12392 var browsePath = document.getElementById("browse-path");
12393 var browseOutputDir = document.getElementById("browse-output-dir");
12394 var browseCoverage = document.getElementById("browse-coverage");
12395 var coverageInput = document.getElementById("coverage_file");
12396 var covScanStatus = document.getElementById("cov-scan-status");
12397 var coverageSuggestTimer = null;
12398 var covAutoFilled = false;
12399 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12400 function fmtBytes(b) {
12401 b = Number(b) || 0;
12402 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12403 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12404 if (b >= 1024) return Math.round(b / 1024) + ' KB';
12405 return b + ' B';
12406 }
12407 var themeToggle = document.getElementById("theme-toggle");
12408
12409 function showBannerToast(msg, isError, opts) {
12410 opts = opts || {};
12411 var t = document.createElement('div');
12412 t.className = isError ? 'toast-error' : 'toast-success';
12413 var topPos = opts.top ? '80px' : null;
12414 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12415 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12416 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12417 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12418 if (opts.icon) {
12419 var inner = document.createElement('span');
12420 inner.innerHTML = opts.icon + ' ';
12421 t.appendChild(inner);
12422 }
12423 t.appendChild(document.createTextNode(msg));
12424 document.body.appendChild(t);
12425 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12426 }
12427 var mixedLinePolicy = document.getElementById("mixed_line_policy");
12428 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12429 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12430 var scanPreset = document.getElementById("scan_preset");
12431 var artifactPreset = document.getElementById("artifact_preset");
12432 var includeGlobsInput = document.getElementById("include_globs");
12433 var excludeGlobsInput = document.getElementById("exclude_globs");
12434
12435 // Quick-exclude chips — append pattern to exclude_globs textarea.
12436 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12437 chip.addEventListener("click", function() {
12438 var pattern = chip.getAttribute("data-pattern") || "";
12439 if (!pattern || !excludeGlobsInput) return;
12440 var current = excludeGlobsInput.value.trim();
12441 // For the "skip all" chip, replace any existing dep patterns cleanly.
12442 var patterns = pattern.split("\n");
12443 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12444 var added = false;
12445 patterns.forEach(function(p) {
12446 p = p.trim();
12447 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12448 });
12449 if (added) {
12450 excludeGlobsInput.value = lines.join("\n");
12451 excludeGlobsInput.dispatchEvent(new Event("input"));
12452 }
12453 chip.classList.add("active");
12454 });
12455 });
12456
12457 var liveReportTitle = document.getElementById("live-report-title");
12458 var navProjectPill = document.getElementById("nav-project-pill");
12459 var navProjectTitle = document.getElementById("nav-project-title");
12460 var reportTitlePreview = null;
12461 var wizardProgressFill = document.getElementById("wizard-progress-fill");
12462 var wizardProgressValue = document.getElementById("wizard-progress-value");
12463 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12464 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12465 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12466 var reportTitleTouched = false;
12467 var currentStep = 1;
12468 var previewTimer = null;
12469 var quickScanBtn = document.getElementById("quick-scan-btn");
12470
12471 function dismissAnalysisModal() {
12472 if (loading) loading.classList.remove("active");
12473 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12474 var el = document.getElementById(id);
12475 if (el) el.classList.add("hidden");
12476 });
12477 var cancelBtn = document.getElementById("lc-cancel-btn");
12478 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12479 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12480 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12481 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12482 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12483 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12484 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12485 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12486 }
12487
12488 var lcDismissBtn = document.getElementById("lc-dismiss");
12489 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12490
12491 function startAsyncAnalysis(formData) {
12492 var gitRepo = (formData.get("git_repo") || "").toString();
12493 var gitRef = (formData.get("git_ref") || "").toString();
12494 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12495 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12496
12497 var pathEl = document.getElementById("lc-path");
12498 if (pathEl) pathEl.textContent = displayPath;
12499
12500 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12501 var el = document.getElementById(id);
12502 if (el) el.classList.add("hidden");
12503 });
12504 var cancelBtn = document.getElementById("lc-cancel-btn");
12505 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12506 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12507 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12508 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12509 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12510 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
12511
12512 if (loading) loading.classList.add("active");
12513
12514 var startTime = Date.now();
12515 var elapsedTimer = setInterval(function() {
12516 var s = Math.floor((Date.now() - startTime) / 1000);
12517 var el = document.getElementById("lc-elapsed");
12518 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12519 }, 1000);
12520
12521 var warnShown = false, pollRetries = 0, activeWaitId = null;
12522
12523 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();}
12524
12525 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12526
12527 function lcShowCancelled() {
12528 clearInterval(elapsedTimer);
12529 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12530 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12531 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12532 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12533 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12534 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12535 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12536 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12537 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12538 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12539 }
12540
12541 var lcCancelBtn = document.getElementById("lc-cancel-btn");
12542 if (lcCancelBtn) {
12543 lcCancelBtn.onclick = function() {
12544 if (!activeWaitId) { dismissAnalysisModal(); return; }
12545 lcCancelBtn.disabled = true;
12546 lcCancelBtn.textContent = "Cancelling…";
12547 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12548 .then(function() { lcShowCancelled(); })
12549 .catch(function() { lcShowCancelled(); });
12550 };
12551 }
12552
12553 function lcShowError(msg) {
12554 clearInterval(elapsedTimer);
12555 lcSetPhase("Failed");
12556 var msgEl = document.getElementById("lc-err-msg");
12557 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12558 var errEl = document.getElementById("lc-err");
12559 var actEl = document.getElementById("lc-actions");
12560 if (errEl) errEl.classList.remove("hidden");
12561 if (actEl) actEl.classList.remove("hidden");
12562 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12563 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12564 }
12565
12566 function lcPoll(waitId) {
12567 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12568 .then(function(r) {
12569 if (!r.ok) throw new Error("HTTP " + r.status);
12570 return r.json();
12571 })
12572 .then(function(data) {
12573 pollRetries = 0;
12574 if (data.state === "complete") {
12575 clearInterval(elapsedTimer);
12576 lcSetPhase("Done");
12577 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12578 } else if (data.state === "failed") {
12579 lcShowError(data.message);
12580 } else if (data.state === "cancelled") {
12581 lcShowCancelled();
12582 } else {
12583 var s = Math.floor((Date.now() - startTime) / 1000);
12584 if (s > 90 && !warnShown) {
12585 warnShown = true;
12586 var w = document.getElementById("lc-warn");
12587 if (w) w.classList.remove("hidden");
12588 }
12589 lcSetPhase(data.phase || "Running");
12590 var fd = data.files_done || 0, ft = data.files_total || 0;
12591 if (ft > 0) {
12592 var card = document.getElementById("lc-files-card");
12593 if (card) card.classList.remove("hidden");
12594 var el = document.getElementById("lc-files");
12595 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
12596 }
12597 setTimeout(function() { lcPoll(waitId); }, 1500);
12598 }
12599 })
12600 .catch(function() {
12601 pollRetries++;
12602 if (pollRetries >= 5) {
12603 lcShowError("Lost connection to server. Reload to check status.");
12604 } else {
12605 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12606 }
12607 });
12608 }
12609
12610 var params = new URLSearchParams(formData);
12611 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12612 .then(function(r) {
12613 var waitId = r.headers.get("x-wait-id");
12614 if (!waitId) { window.location.href = "/scan"; return; }
12615 activeWaitId = waitId;
12616 setTimeout(function() { lcPoll(waitId); }, 1500);
12617 })
12618 .catch(function(err) {
12619 lcShowError("Could not reach server: " + (err.message || err));
12620 });
12621 }
12622
12623 if (quickScanBtn) {
12624 quickScanBtn.addEventListener("click", function () {
12625 var pathVal = pathInput ? pathInput.value.trim() : "";
12626 if (!pathVal) {
12627 alert("Please enter or browse to a project path first.");
12628 return;
12629 }
12630 quickScanBtn.disabled = true;
12631 quickScanBtn.textContent = "Scanning...";
12632 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12633 startAsyncAnalysis(new FormData(form));
12634 });
12635 }
12636
12637 var mixedPolicyInfo = {
12638 code_only: {
12639 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.",
12640 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'
12641 },
12642 code_and_comment: {
12643 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.",
12644 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'
12645 },
12646 comment_only: {
12647 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.",
12648 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'
12649 },
12650 separate_mixed_category: {
12651 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.",
12652 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'
12653 }
12654 };
12655
12656 var scanPresetInfo = {
12657 balanced: {
12658 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.",
12659 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12660 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12661 note: "Best when you want a stable local overview before making deeper adjustments.",
12662 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12663 },
12664 code_focused: {
12665 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12666 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12667 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12668 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12669 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12670 },
12671 comment_audit: {
12672 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12673 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12674 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12675 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12676 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12677 },
12678 deep_review: {
12679 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12680 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12681 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12682 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12683 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12684 }
12685 };
12686
12687 var artifactPresetInfo = {
12688 review: {
12689 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.",
12690 chips: ["HTML", "PDF"],
12691 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12692 },
12693 full: {
12694 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.",
12695 chips: ["HTML", "PDF", "JSON"],
12696 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12697 },
12698 html_only: {
12699 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.",
12700 chips: ["HTML only", "Fast local review"],
12701 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12702 },
12703 machine: {
12704 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12705 chips: ["HTML", "JSON"],
12706 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12707 }
12708 };
12709
12710 function applyTheme(theme) {
12711 if (theme === "dark") document.body.classList.add("dark-theme");
12712 else document.body.classList.remove("dark-theme");
12713 }
12714
12715 function loadSavedTheme() {
12716 var saved = null;
12717 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12718 applyTheme(saved === "dark" ? "dark" : "light");
12719 }
12720
12721 function updateScrollProgress() {
12722 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12723 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12724 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12725 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12726 var step = Math.min(Math.max(currentStep, 1), 4);
12727 var base = stepBase[step];
12728 var end = stepEnd[step];
12729
12730 var scrollFrac = 0;
12731 var activePanel = document.querySelector(".wizard-step.active");
12732 if (activePanel) {
12733 var scrollTop = window.scrollY || window.pageYOffset || 0;
12734 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12735 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12736 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12737 var scrolled = scrollTop + viewH - panelTop;
12738 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12739 }
12740
12741 var percent = Math.round(base + (end - base) * scrollFrac);
12742 percent = Math.min(end, Math.max(base, percent));
12743 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12744 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12745 }
12746
12747 function updateWizardProgress() {
12748 updateScrollProgress();
12749 }
12750
12751 var stepDescriptions = [
12752 "Choose a project folder, apply scope filters, and preview which files will be counted.",
12753 "Configure how mixed code-plus-comment lines and docstrings are classified.",
12754 "Pick your output formats, scan preset, and where reports are saved.",
12755 "Review all settings and launch the analysis."
12756 ];
12757
12758 function updateStepNav(step) {
12759 var infoLabel = document.getElementById("step-nav-info-label");
12760 var infoDesc = document.getElementById("step-nav-info-desc");
12761 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12762 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
12763 }
12764
12765 function updateSidebarSummary() {
12766 var sumPath = document.getElementById("sum-path");
12767 var sumPreset = document.getElementById("sum-preset");
12768 var sumOutput = document.getElementById("sum-output");
12769 var sidebarSummary = document.getElementById("sidebar-summary");
12770 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12771 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
12772 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12773 if (sumPath) sumPath.textContent = pathVal || "—";
12774 if (sumPreset) sumPreset.textContent = presetVal || "—";
12775 if (sumOutput) sumOutput.textContent = outputVal || "—";
12776 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12777 }
12778
12779 function setStep(step, pushHistory) {
12780 currentStep = step;
12781 stepPanels.forEach(function (panel) {
12782 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12783 });
12784 stepButtons.forEach(function (button) {
12785 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12786 });
12787 var layoutEl = document.querySelector(".layout");
12788 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12789 updateWizardProgress();
12790 updateStepNav(step);
12791 stepButtons.forEach(function(btn) {
12792 var t = Number(btn.getAttribute("data-step-target"));
12793 btn.classList.toggle("done", t < step);
12794 });
12795 updateSidebarSummary();
12796
12797 if (pushHistory !== false) {
12798 try {
12799 history.pushState({ wizardStep: step }, "", "#step" + step);
12800 } catch (e) {}
12801 }
12802
12803 window.scrollTo({ top: 0, behavior: "instant" });
12804 }
12805
12806 window.addEventListener("popstate", function (e) {
12807 if (e.state && e.state.wizardStep) {
12808 setStep(e.state.wizardStep, false);
12809 } else {
12810 var hashMatch = location.hash.match(/^#step([1-4])$/);
12811 if (hashMatch) setStep(Number(hashMatch[1]), false);
12812 }
12813 });
12814
12815 function inferTitleFromPath(value) {
12816 if (!value) return "project";
12817 var cleaned = value.replace(/[\/\\]+$/, "");
12818 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12819 return parts.length ? parts[parts.length - 1] : value;
12820 }
12821
12822 function updateReportTitleFromPath() {
12823 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12824 if (!reportTitleTouched) {
12825 reportTitleInput.value = inferred;
12826 }
12827 var title = reportTitleInput.value || inferred;
12828 if (liveReportTitle) liveReportTitle.textContent = title;
12829 if (reportTitlePreview) reportTitlePreview.textContent = title;
12830 document.title = "OxideSLOC | " + title;
12831
12832 var projectPath = (pathInput.value || "").trim();
12833 if (navProjectPill && navProjectTitle) {
12834 if (projectPath.length > 0) {
12835 navProjectTitle.textContent = inferred;
12836 navProjectPill.classList.add("visible");
12837 } else {
12838 navProjectTitle.textContent = "";
12839 navProjectPill.classList.remove("visible");
12840 }
12841 }
12842 }
12843
12844 function updateMixedPolicyUI() {
12845 var key = mixedLinePolicy.value || "code_only";
12846 var info = mixedPolicyInfo[key];
12847 document.getElementById("mixed-policy-description").textContent = info.description;
12848 document.getElementById("mixed-policy-example").textContent = info.example;
12849 }
12850
12851 function updatePythonDocstringUI() {
12852 var checked = !!pythonDocstrings.checked;
12853 document.getElementById("python-docstring-example").textContent = checked
12854 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
12855 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
12856 document.getElementById("python-docstring-live-help").textContent = checked
12857 ? "Enabled: docstrings contribute to comment-style totals."
12858 : "Disabled: docstrings are not counted as comment content.";
12859 }
12860
12861 function renderPresetChips(targetId, chips) {
12862 var target = document.getElementById(targetId);
12863 if (!target) return;
12864 target.innerHTML = (chips || []).map(function (chip) {
12865 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12866 }).join('');
12867 }
12868
12869 function updatePresetDescriptions() {
12870 var scanInfo = scanPresetInfo[scanPreset.value];
12871 var artifactInfo = artifactPresetInfo[artifactPreset.value];
12872 document.getElementById("scan-preset-description").textContent = scanInfo.description;
12873 document.getElementById("scan-preset-example").textContent = scanInfo.example;
12874 document.getElementById("scan-preset-note").textContent = scanInfo.note;
12875 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12876 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12877 renderPresetChips("scan-preset-summary", scanInfo.chips);
12878 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12879 }
12880
12881 function applyScanPreset() {
12882 var info = scanPresetInfo[scanPreset.value];
12883 if (!info || !info.apply) return;
12884 mixedLinePolicy.value = info.apply.mixed;
12885 pythonDocstrings.checked = !!info.apply.docstrings;
12886 document.getElementById("generated_file_detection").value = info.apply.generated;
12887 document.getElementById("minified_file_detection").value = info.apply.minified;
12888 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12889 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12890 document.getElementById("binary_file_behavior").value = info.apply.binary;
12891 updateMixedPolicyUI();
12892 updatePythonDocstringUI();
12893 }
12894
12895 function applyArtifactPreset() {
12896 var enabled = { html: false, pdf: false };
12897 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12898 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12899 if (artifactPreset.value === "html_only") { enabled.html = true; }
12900 if (artifactPreset.value === "machine") { enabled.html = true; }
12901
12902 artifactCards.forEach(function (card) {
12903 var artifact = card.getAttribute("data-artifact");
12904 if (artifact === "json") return;
12905 var checked = !!enabled[artifact];
12906 var checkbox = card.querySelector(".artifact-checkbox");
12907 checkbox.checked = checked;
12908 card.classList.toggle("selected", checked);
12909 });
12910 }
12911
12912 function toggleArtifactCard(card) {
12913 var checkbox = card.querySelector(".artifact-checkbox");
12914 checkbox.checked = !checkbox.checked;
12915 card.classList.toggle("selected", checkbox.checked);
12916 }
12917
12918 function updateReview() {
12919 var scanSummary = document.getElementById("review-scan-summary");
12920 var countSummary = document.getElementById("review-count-summary");
12921 var artifactSummary = document.getElementById("review-artifact-summary");
12922 var outputSummary = document.getElementById("review-output-summary");
12923 var previewSummary = document.getElementById("review-preview-summary");
12924 var readinessSummary = document.getElementById("review-readiness-summary");
12925 var includeText = document.getElementById("include_globs").value.trim();
12926 var excludeText = document.getElementById("exclude_globs").value.trim();
12927 var sidePathPreview = document.getElementById("side-path-preview");
12928 var sideOutputPreview = document.getElementById("side-output-preview");
12929 var sideTitlePreview = document.getElementById("side-title-preview");
12930
12931 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12932 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12933 if (sideTitlePreview) {
12934 var rt = document.getElementById("report_title");
12935 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12936 }
12937
12938 scanSummary.innerHTML = ""
12939 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12940 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12941 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12942
12943 countSummary.innerHTML = ""
12944 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12945 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12946 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12947 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12948 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12949 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12950 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12951 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12952
12953 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12954 artifactSummary.innerHTML = ""
12955 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12956 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12957
12958 outputSummary.innerHTML = ""
12959 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12960 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12961
12962 if (previewSummary) {
12963 if (GIT_MODE) {
12964 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>';
12965 } else {
12966 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12967 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12968 var statMap = {};
12969 statButtons.forEach(function (button) {
12970 var valueNode = button.querySelector('.scope-stat-value');
12971 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12972 });
12973 previewSummary.innerHTML = ''
12974 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12975 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12976 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12977 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12978 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12979 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12980
12981 if (readinessSummary) {
12982 var selectedArtifactsCount = selectedArtifacts.length;
12983 readinessSummary.innerHTML = ''
12984 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12985 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12986 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12987 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12988 }
12989 } // end else (non-GIT_MODE)
12990 }
12991 }
12992
12993 function escapeHtml(value) {
12994 return String(value)
12995 .replace(/&/g, "&")
12996 .replace(/</g, "<")
12997 .replace(/>/g, ">")
12998 .replace(/"/g, """)
12999 .replace(/'/g, "'");
13000 }
13001
13002 function isPythonVisible() {
13003 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
13004 }
13005
13006 function syncPythonVisibility() {
13007 var html = previewPanel.textContent || "";
13008 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
13009 pythonWraps.forEach(function (node) {
13010 node.classList.toggle("hidden", !hasPython);
13011 });
13012 }
13013
13014 function attachPreviewInteractions() {
13015 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
13016 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
13017 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
13018 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
13019 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
13020 var searchInput = previewPanel.querySelector("#explorer-search");
13021 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
13022 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
13023 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
13024 var activeFilter = "all";
13025 var activeLanguage = "";
13026 var searchTerm = "";
13027 var currentSortKey = null;
13028 var currentSortOrder = "asc";
13029 var childRows = {};
13030
13031 rows.forEach(function (row) {
13032 var parentId = row.getAttribute("data-parent-id") || "";
13033 var rowId = row.getAttribute("data-row-id") || "";
13034 if (!childRows[parentId]) childRows[parentId] = [];
13035 childRows[parentId].push(rowId);
13036 });
13037
13038 function rowById(id) {
13039 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
13040 }
13041
13042 function hasCollapsedAncestor(row) {
13043 var parentId = row.getAttribute("data-parent-id");
13044 while (parentId) {
13045 var parent = rowById(parentId);
13046 if (!parent) break;
13047 if (parent.getAttribute("data-expanded") === "false") return true;
13048 parentId = parent.getAttribute("data-parent-id");
13049 }
13050 return false;
13051 }
13052
13053 function updateToggleGlyph(row) {
13054 var toggle = row.querySelector(".tree-toggle");
13055 if (!toggle) return;
13056 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
13057 }
13058
13059 function rowSortValue(row, key) {
13060 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
13061 }
13062
13063 function updateSortButtons() {
13064 sortButtons.forEach(function (button) {
13065 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
13066 var indicator = button.querySelector(".tree-sort-indicator");
13067 button.classList.toggle("active", isActive);
13068 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
13069 if (indicator) {
13070 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
13071 }
13072 });
13073 }
13074
13075 function sortSiblingRows() {
13076 if (!treeContainer) {
13077 updateSortButtons();
13078 return;
13079 }
13080
13081 var rowMap = {};
13082 var childrenMap = {};
13083 rows.forEach(function (row) {
13084 var rowId = row.getAttribute("data-row-id");
13085 var parentId = row.getAttribute("data-parent-id") || "";
13086 rowMap[rowId] = row;
13087 if (!childrenMap[parentId]) childrenMap[parentId] = [];
13088 childrenMap[parentId].push(rowId);
13089 });
13090
13091 Object.keys(childrenMap).forEach(function (parentId) {
13092 if (!parentId) return;
13093 childrenMap[parentId].sort(function (a, b) {
13094 var rowA = rowMap[a];
13095 var rowB = rowMap[b];
13096 if (!currentSortKey) {
13097 return Number(a) - Number(b);
13098 }
13099 var valueA = rowSortValue(rowA, currentSortKey);
13100 var valueB = rowSortValue(rowB, currentSortKey);
13101 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
13102 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
13103 var fallbackA = rowSortValue(rowA, "name");
13104 var fallbackB = rowSortValue(rowB, "name");
13105 if (fallbackA < fallbackB) return -1;
13106 if (fallbackA > fallbackB) return 1;
13107 return Number(a) - Number(b);
13108 });
13109 });
13110
13111 var orderedIds = [];
13112 function pushChildren(parentId) {
13113 (childrenMap[parentId] || []).forEach(function (childId) {
13114 orderedIds.push(childId);
13115 pushChildren(childId);
13116 });
13117 }
13118
13119 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
13120 orderedIds.push(topId);
13121 pushChildren(topId);
13122 });
13123
13124 orderedIds.forEach(function (id) {
13125 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
13126 });
13127 updateSortButtons();
13128 }
13129
13130 function updateLanguageButtons() {
13131 languageButtons.forEach(function (button) {
13132 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
13133 var isActive = languageValue === activeLanguage;
13134 button.classList.toggle("active", isActive);
13135 });
13136 }
13137
13138 function rowSelfMatches(row) {
13139 var kind = row.getAttribute("data-kind");
13140 var status = row.getAttribute("data-status");
13141 var language = (row.getAttribute("data-language") || "").toLowerCase();
13142 var name = row.getAttribute("data-name-lower") || "";
13143 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
13144 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
13145 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
13146 var passesLanguage = !activeLanguage || language === activeLanguage;
13147 return passesFilter && passesSearch && passesLanguage;
13148 }
13149
13150 function hasMatchingDescendant(rowId) {
13151 return (childRows[rowId] || []).some(function (childId) {
13152 var childRow = rowById(childId);
13153 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
13154 });
13155 }
13156
13157 function rowMatches(row) {
13158 if (rowSelfMatches(row)) return true;
13159 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
13160 }
13161
13162 function resetViewState() {
13163 activeFilter = "all";
13164 activeLanguage = "";
13165 searchTerm = "";
13166 currentSortKey = null;
13167 currentSortOrder = "asc";
13168 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13169 if (searchInput) searchInput.value = "";
13170 if (filterSelect) filterSelect.value = "all";
13171 updateLanguageButtons();
13172 }
13173
13174 function applyVisibility() {
13175 rows.forEach(function (row) {
13176 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
13177 row.classList.toggle("hidden-by-filter", !visible);
13178 row.style.display = visible ? "grid" : "none";
13179 });
13180 buttons.forEach(function (button) {
13181 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
13182 });
13183 if (filterSelect) filterSelect.value = activeFilter;
13184 }
13185
13186 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
13187 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
13188 var originalStats = {};
13189 buttons.forEach(function (btn) {
13190 var f = btn.getAttribute('data-filter');
13191 var v = btn.querySelector('.scope-stat-value');
13192 if (f && v) originalStats[f] = v.textContent;
13193 });
13194
13195 function applySubmoduleStats(statsJson) {
13196 try {
13197 var s = JSON.parse(statsJson);
13198 buttons.forEach(function (btn) {
13199 var f = btn.getAttribute('data-filter');
13200 var v = btn.querySelector('.scope-stat-value');
13201 if (!v) return;
13202 if (f === 'dir') v.textContent = s.dirs;
13203 else if (f === 'file') v.textContent = s.files;
13204 else if (f === 'supported') v.textContent = s.supported;
13205 else if (f === 'skipped') v.textContent = s.skipped;
13206 else if (f === 'unsupported') v.textContent = s.unsupported;
13207 });
13208 } catch (e) {}
13209 }
13210
13211 function restoreBaseRepoStats() {
13212 buttons.forEach(function (btn) {
13213 var f = btn.getAttribute('data-filter');
13214 var v = btn.querySelector('.scope-stat-value');
13215 if (v && originalStats[f]) v.textContent = originalStats[f];
13216 });
13217 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13218 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
13219 }
13220
13221 submoduleChips.forEach(function (chip) {
13222 chip.addEventListener('click', function () {
13223 var statsJson = chip.getAttribute('data-sub-stats');
13224 if (!statsJson) return;
13225 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13226 chip.classList.add('active');
13227 applySubmoduleStats(statsJson);
13228 if (baseRepoBtn) baseRepoBtn.style.display = '';
13229 });
13230 });
13231
13232 if (baseRepoBtn) {
13233 baseRepoBtn.addEventListener('click', function () {
13234 restoreBaseRepoStats();
13235 resetViewState();
13236 sortSiblingRows();
13237 applyVisibility();
13238 });
13239 }
13240
13241 buttons.forEach(function (button) {
13242 button.addEventListener("click", function () {
13243 var filterValue = button.getAttribute("data-filter") || "all";
13244 if (filterValue === "reset-view") {
13245 restoreBaseRepoStats();
13246 resetViewState();
13247 sortSiblingRows();
13248 applyVisibility();
13249 return;
13250 }
13251 activeFilter = filterValue;
13252 applyVisibility();
13253 });
13254 });
13255
13256 rows.forEach(function (row) {
13257 updateToggleGlyph(row);
13258 var toggle = row.querySelector(".tree-toggle");
13259 if (toggle) {
13260 toggle.addEventListener("click", function () {
13261 var expanded = row.getAttribute("data-expanded") !== "false";
13262 row.setAttribute("data-expanded", expanded ? "false" : "true");
13263 updateToggleGlyph(row);
13264 applyVisibility();
13265 });
13266 }
13267 });
13268
13269 actionButtons.forEach(function (button) {
13270 button.addEventListener("click", function () {
13271 var action = button.getAttribute("data-explorer-action");
13272 if (action === "expand-all") {
13273 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13274 } else if (action === "collapse-all") {
13275 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
13276 } else if (action === "clear-filters") {
13277 resetViewState();
13278 }
13279 sortSiblingRows();
13280 applyVisibility();
13281 });
13282 });
13283
13284 if (filterSelect) {
13285 filterSelect.addEventListener("change", function () {
13286 activeFilter = filterSelect.value || "all";
13287 applyVisibility();
13288 });
13289 }
13290
13291 languageButtons.forEach(function (button) {
13292 button.addEventListener("click", function () {
13293 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
13294 updateLanguageButtons();
13295 applyVisibility();
13296 });
13297 });
13298
13299 sortButtons.forEach(function (button) {
13300 button.addEventListener("click", function () {
13301 var sortKey = button.getAttribute("data-sort-key");
13302 if (currentSortKey === sortKey) {
13303 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
13304 } else {
13305 currentSortKey = sortKey;
13306 currentSortOrder = "asc";
13307 }
13308 sortSiblingRows();
13309 applyVisibility();
13310 });
13311 });
13312
13313 if (searchInput) {
13314 searchInput.addEventListener("input", function () {
13315 searchTerm = searchInput.value.trim().toLowerCase();
13316 applyVisibility();
13317 });
13318 }
13319
13320 updateLanguageButtons();
13321 sortSiblingRows();
13322 applyVisibility();
13323 }
13324
13325 function loadPreview() {
13326 if (!previewPanel || !pathInput) return;
13327 if (GIT_MODE) {
13328 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>';
13329 return;
13330 }
13331 var path = pathInput.value.trim();
13332 var zeroWarn = document.getElementById('zero-files-warning');
13333 if (!path) {
13334 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13335 if (zeroWarn) zeroWarn.style.display = 'none';
13336 return;
13337 }
13338 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13339 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13340 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
13341 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
13342 var _prevMsgs = [
13343 'Scanning directory structure…',
13344 'Detecting file types…',
13345 'Applying include / exclude filters…',
13346 'Estimating file counts…',
13347 'Building scope preview…',
13348 'Almost there…'
13349 ];
13350 var _prevMsgIdx = 0;
13351 var _prevStart = Date.now();
13352 previewPanel.innerHTML =
13353 '<div class="preview-loading">' +
13354 '<div class="preview-spinner"></div>' +
13355 '<div class="preview-loading-text">' +
13356 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
13357 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
13358 '</div></div>';
13359 var _sizeTextEl = document.getElementById('project-size-text');
13360 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
13361 window._previewInterval = setInterval(function() {
13362 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
13363 var ml = document.getElementById('plm');
13364 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
13365 }, 1500);
13366 window._previewElapsedTimer = setInterval(function() {
13367 var el = document.getElementById('ple');
13368 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
13369 }, 1000);
13370 var previewUrl = "/preview?path=" + encodeURIComponent(path)
13371 + "&include_globs=" + encodeURIComponent(includeValue)
13372 + "&exclude_globs=" + encodeURIComponent(excludeValue);
13373 fetch(previewUrl)
13374 .then(function (response) { return response.text(); })
13375 .then(function (html) {
13376 clearInterval(window._previewInterval); window._previewInterval = null;
13377 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13378 previewPanel.innerHTML = html;
13379 attachPreviewInteractions();
13380 syncPythonVisibility();
13381 updateReview();
13382 setTimeout(collapseLanguagePills, 50);
13383 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13384 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13385 var sizeText = document.getElementById('project-size-text');
13386 var sizeBtn = document.getElementById('project-size-btn');
13387 // In server mode with upload sizes available, keep the compressed/original pair.
13388 if (SERVER_MODE && window._lastUploadSizes) {
13389 var us = window._lastUploadSizes;
13390 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13391 ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13392 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13393 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13394 } else if (sizeText && projectSize) {
13395 sizeText.textContent = 'Project size: ' + projectSize;
13396 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13397 } else if (sizeText) {
13398 sizeText.textContent = 'Project size: —';
13399 }
13400 if (zeroWarn) {
13401 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13402 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13403 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13404 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13405 if (supportedCount === 0 && fileCount > 0) {
13406 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).';
13407 zeroWarn.style.display = '';
13408 } else {
13409 zeroWarn.style.display = 'none';
13410 }
13411 }
13412 })
13413 .catch(function (err) {
13414 clearInterval(window._previewInterval); window._previewInterval = null;
13415 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13416 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13417 });
13418 }
13419
13420 function pickDirectory(targetInput, kind) {
13421 if (SERVER_MODE) {
13422 if (kind === 'output') {
13423 showBannerToast(
13424 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13425 false,
13426 { top: true, icon: '📁' }
13427 );
13428 return;
13429 }
13430 var inputEl = kind === 'coverage'
13431 ? document.getElementById('cov-upload-input')
13432 : document.getElementById('dir-upload-input');
13433 if (!inputEl) return;
13434 inputEl.onchange = function () {
13435 var files = inputEl.files;
13436 if (!files || files.length === 0) return;
13437 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13438 if (browseBtn) browseBtn.disabled = true;
13439
13440 function fileToBase64(file) {
13441 return new Promise(function (resolve, reject) {
13442 var reader = new FileReader();
13443 reader.onload = function () {
13444 var b64 = reader.result.split(',')[1];
13445 resolve(b64);
13446 };
13447 reader.onerror = reject;
13448 reader.readAsDataURL(file);
13449 });
13450 }
13451
13452 if (kind === 'coverage') {
13453 var f = files[0];
13454 if (previewPanel && targetInput === pathInput)
13455 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13456 fileToBase64(f).then(function (b64) {
13457 return fetch('/api/upload-file', {
13458 method: 'POST',
13459 headers: { 'Content-Type': 'application/json' },
13460 body: JSON.stringify({ filename: f.name, content: b64 })
13461 }).then(function (r) { return r.json(); });
13462 })
13463 .then(function (d) {
13464 if (d && d.tmp_path) {
13465 if (coverageInput) coverageInput.value = d.tmp_path;
13466 setCovStatus('idle');
13467 } else if (d && d.error) { showBannerToast(d.error, true); }
13468 })
13469 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13470 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13471 } else {
13472 // ── Filter to source-code files only ─────────────────────────
13473 // Binary, generated, and dependency files (node_modules, .git,
13474 // build artifacts) are skipped so they are never uploaded.
13475 var CODE_EXTS = new Set([
13476 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13477 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13478 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13479 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13480 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13481 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13482 'tf','hcl','proto','thrift','avsc','graphql','gql'
13483 ]);
13484 var codeFiles = [];
13485 for (var i = 0; i < files.length; i++) {
13486 var f = files[i];
13487 var name = f.name;
13488 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13489 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13490 codeFiles.push(f); continue;
13491 }
13492 var dot = name.lastIndexOf('.');
13493 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13494 }
13495 // Collect specific .git metadata files for server-side git detection.
13496 // These have no source extension so they are excluded by the loop above,
13497 // but the server needs them to read branch/commit/author without running git.
13498 var gitMetaFiles = [];
13499 for (var i = 0; i < files.length; i++) {
13500 var f = files[i];
13501 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13502 var gitIdx = rp.indexOf('/.git/');
13503 if (gitIdx < 0) continue;
13504 var gitRel = rp.slice(gitIdx + 1);
13505 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13506 gitRel === '.git/logs/HEAD' ||
13507 gitRel.startsWith('.git/refs/heads/') ||
13508 gitRel.startsWith('.git/refs/tags/')) {
13509 gitMetaFiles.push(f);
13510 }
13511 }
13512 var uploadFiles = codeFiles.concat(gitMetaFiles);
13513 var total = files.length;
13514 var kept = codeFiles.length;
13515 if (kept === 0) {
13516 if (previewPanel && targetInput === pathInput)
13517 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13518 if (browseBtn) browseBtn.disabled = false;
13519 inputEl.value = '';
13520 return;
13521 }
13522
13523 // ── Helper: apply upload result to UI ────────────────────────
13524 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13525 function applyUploadResult(tmpPath, sizes) {
13526 targetInput.value = tmpPath;
13527 scrollInputToEnd(targetInput);
13528 if (sizes && SERVER_MODE) {
13529 window._lastUploadSizes = sizes;
13530 // Immediately show both sizes before preview loads.
13531 var sizeText = document.getElementById('project-size-text');
13532 var sizeBtn = document.getElementById('project-size-btn');
13533 if (sizeText) {
13534 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13535 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13536 }
13537 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13538 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13539 }
13540 if (targetInput === pathInput) {
13541 updateReportTitleFromPath();
13542 autoSetOutputDir(tmpPath);
13543 fetchProjectHistory(tmpPath);
13544 loadPreview();
13545 suggestCoverageFile(tmpPath);
13546 }
13547 updateReview();
13548 if (browseBtn) browseBtn.disabled = false;
13549 inputEl.value = '';
13550 }
13551
13552 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13553 if (typeof CompressionStream !== 'undefined') {
13554 if (previewPanel && targetInput === pathInput)
13555 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13556
13557 // Build a minimal POSIX ustar tar header for a single file entry.
13558 function buildUstarHeader(filePath, fileSize) {
13559 var BLOCK = 512;
13560 var hdr = new Uint8Array(BLOCK);
13561 var enc = new TextEncoder();
13562 function wStr(off, len, s) {
13563 var b = enc.encode(s);
13564 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13565 }
13566 function wOct(off, len, val) {
13567 var s = val.toString(8);
13568 while (s.length < len - 1) s = '0' + s;
13569 wStr(off, len, s + '\0');
13570 }
13571 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13572 var name = filePath, prefix = '';
13573 if (filePath.length > 99) {
13574 var split = filePath.lastIndexOf('/', 154);
13575 if (split > 0 && filePath.length - split - 1 <= 99) {
13576 prefix = filePath.substring(0, split);
13577 name = filePath.substring(split + 1);
13578 } else { name = filePath.substring(0, 99); }
13579 }
13580 wStr(0, 100, name); // name
13581 wOct(100, 8, 0o000644); // mode
13582 wOct(108, 8, 0); // uid
13583 wOct(116, 8, 0); // gid
13584 wOct(124, 12, fileSize); // size
13585 wOct(136, 12, 0); // mtime (epoch)
13586 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13587 hdr[156] = 48; // type flag '0' = regular file
13588 wStr(157, 100, ''); // linkname
13589 wStr(257, 6, 'ustar'); // magic
13590 wStr(263, 2, '00'); // version
13591 wStr(265, 32, ''); // uname
13592 wStr(297, 32, ''); // gname
13593 wOct(329, 8, 0); // devmajor
13594 wOct(337, 8, 0); // devminor
13595 wStr(345, 155, prefix); // prefix
13596 // Compute checksum (sum of all bytes, placeholder = 32).
13597 var chk = 0;
13598 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13599 var cs = chk.toString(8);
13600 while (cs.length < 6) cs = '0' + cs;
13601 wStr(148, 8, cs + '\0 ');
13602 return hdr;
13603 }
13604
13605 // Build tar.gz one file at a time, piping through CompressionStream.
13606 // RAM usage = compressed output buffer + one file at a time.
13607 (async function () {
13608 try {
13609 var BLOCK = 512;
13610 var cs = new CompressionStream('gzip');
13611 var writer = cs.writable.getWriter();
13612 var chunks = [];
13613 var reader = cs.readable.getReader();
13614 var collecting = (async function () {
13615 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13616 })();
13617
13618 for (var i = 0; i < uploadFiles.length; i++) {
13619 var file = uploadFiles[i];
13620 var path = file.webkitRelativePath || file.name;
13621 var buf = await file.arrayBuffer();
13622 var data = new Uint8Array(buf);
13623 // Header block
13624 await writer.write(buildUstarHeader(path, data.length));
13625 // Data padded to 512-byte boundary
13626 if (data.length > 0) {
13627 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13628 var block = new Uint8Array(padded);
13629 block.set(data);
13630 await writer.write(block);
13631 }
13632 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13633 if (previewPanel && targetInput === pathInput)
13634 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13635 }
13636 }
13637 // End-of-archive: two 512-byte zero blocks
13638 await writer.write(new Uint8Array(BLOCK * 2));
13639 await writer.close();
13640 await collecting;
13641
13642 var blob = new Blob(chunks, { type: 'application/gzip' });
13643 var sizeMB = (blob.size / 1048576).toFixed(1);
13644 if (previewPanel && targetInput === pathInput)
13645 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13646
13647 var resp = await fetch('/api/upload-tarball', {
13648 method: 'POST',
13649 headers: { 'Content-Type': 'application/gzip' },
13650 body: blob
13651 });
13652 var d = await resp.json();
13653 if (d && d.tmp_path) {
13654 applyUploadResult(d.tmp_path, {
13655 compressed_bytes: d.compressed_bytes || 0,
13656 original_bytes: d.original_bytes || 0
13657 });
13658 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13659 } catch (e) {
13660 showBannerToast('Upload failed: ' + String(e), true);
13661 if (browseBtn) browseBtn.disabled = false;
13662 inputEl.value = '';
13663 }
13664 })();
13665
13666 } else {
13667 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13668 // Used only on browsers that lack CompressionStream (pre-2023).
13669 var BATCH = 200;
13670 var batches = [];
13671 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13672 var totalBatches = batches.length;
13673 if (previewPanel && targetInput === pathInput)
13674 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13675
13676 function sendBatch(idx, currentUploadId, lastTmpPath) {
13677 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13678 if (previewPanel && targetInput === pathInput && totalBatches > 1)
13679 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13680 Promise.all(batches[idx].map(function (file) {
13681 return fileToBase64(file).then(function (b64) {
13682 return { path: file.webkitRelativePath || file.name, content: b64 };
13683 });
13684 })).then(function (fileList) {
13685 var body = { files: fileList };
13686 if (currentUploadId) body.upload_id = currentUploadId;
13687 return fetch('/api/upload-directory', {
13688 method: 'POST', headers: { 'Content-Type': 'application/json' },
13689 body: JSON.stringify(body)
13690 }).then(function (r) { return r.json(); });
13691 }).then(function (d) {
13692 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13693 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13694 }).catch(function (e) {
13695 showBannerToast('Upload failed: ' + String(e), true);
13696 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13697 });
13698 }
13699 sendBatch(0, null, '');
13700 }
13701 }
13702 };
13703 inputEl.click();
13704 return;
13705 }
13706
13707 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13708 if (browseButton) browseButton.disabled = true;
13709
13710 if (previewPanel && targetInput === pathInput) {
13711 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13712 }
13713
13714 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
13715 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13716 .then(function (data) {
13717 if (data && data.selected_path) {
13718 targetInput.value = data.selected_path;
13719 scrollInputToEnd(targetInput);
13720
13721 if (targetInput === pathInput) {
13722 updateReportTitleFromPath();
13723 autoSetOutputDir(data.selected_path);
13724 fetchProjectHistory(data.selected_path);
13725 loadPreview();
13726 suggestCoverageFile(data.selected_path);
13727 }
13728
13729 updateReview();
13730 } else if (targetInput === pathInput) {
13731 loadPreview();
13732 }
13733 })
13734 .catch(function () {
13735 window.alert("Directory picker request failed.");
13736 if (previewPanel && targetInput === pathInput) {
13737 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13738 }
13739 })
13740 .finally(function () {
13741 if (browseButton) browseButton.disabled = false;
13742 });
13743 }
13744
13745 if (themeToggle) {
13746 themeToggle.addEventListener("click", function () {
13747 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13748 applyTheme(nextTheme);
13749 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13750 });
13751 }
13752
13753 stepButtons.forEach(function (button) {
13754 button.addEventListener("click", function () {
13755 setStep(Number(button.getAttribute("data-step-target")));
13756 });
13757 });
13758
13759 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13760 button.addEventListener("click", function () {
13761 setStep(Number(button.getAttribute("data-step-target")) || 1);
13762 });
13763 });
13764
13765 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13766 button.addEventListener("click", function () {
13767 updateReview();
13768 setStep(Number(button.getAttribute("data-next")));
13769 });
13770 });
13771
13772 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13773 button.addEventListener("click", function () {
13774 setStep(Number(button.getAttribute("data-prev")));
13775 });
13776 });
13777
13778 document.addEventListener("keydown", function (e) {
13779 var tag = (document.activeElement || {}).tagName || "";
13780 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13781 if (e.altKey || e.ctrlKey || e.metaKey) return;
13782 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13783 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13784 });
13785
13786 if (useSamplePath) {
13787 useSamplePath.addEventListener("click", function () {
13788 pathInput.value = "tests/fixtures/basic";
13789 updateReportTitleFromPath();
13790 autoSetOutputDir("tests/fixtures/basic");
13791 loadPreview();
13792 suggestCoverageFile("tests/fixtures/basic");
13793 });
13794 }
13795
13796 if (useDefaultOutput) {
13797 useDefaultOutput.addEventListener("click", function () {
13798 delete outputDirInput.dataset.userEdited;
13799 autoSetOutputDir(pathInput ? pathInput.value : "");
13800 updateReview();
13801 });
13802 }
13803
13804 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13805 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13806
13807 // ── Drag-and-drop directory upload (server mode only) ─────────────────
13808 // Dropping a folder onto the path field bypasses Chrome's
13809 // "Upload X files to this site?" confirmation dialog.
13810 async function readDirRecursively(dirEntry, basePath) {
13811 var reader = dirEntry.createReader();
13812 var all = [];
13813 for (;;) {
13814 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13815 if (!batch.length) break;
13816 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13817 }
13818 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13819 var out = [];
13820 for (var i = 0; i < all.length; i++) {
13821 var sub = all[i];
13822 if (sub.isFile) {
13823 var f = await new Promise(function(res) { sub.file(res); });
13824 out.push({ file: f, path: basePath + '/' + sub.name });
13825 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13826 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13827 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13828 }
13829 }
13830 return out;
13831 }
13832
13833 function setupPathDropZone() {
13834 if (!SERVER_MODE || !pathInput) return;
13835 var CODE_EXTS = new Set([
13836 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13837 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13838 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13839 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13840 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13841 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13842 ]);
13843 pathInput.addEventListener('dragover', function(e) {
13844 e.preventDefault();
13845 pathInput.classList.add('drag-over');
13846 });
13847 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13848 pathInput.addEventListener('drop', function(e) {
13849 e.preventDefault();
13850 pathInput.classList.remove('drag-over');
13851 var items = e.dataTransfer.items;
13852 if (!items || !items.length) return;
13853 var dirEntry = null;
13854 for (var i = 0; i < items.length; i++) {
13855 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13856 if (entry && entry.isDirectory) { dirEntry = entry; break; }
13857 }
13858 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13859 var btn = browsePath;
13860 if (btn) btn.disabled = true;
13861 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13862
13863 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13864 var total = allEntries.length;
13865 var codeEntries = allEntries.filter(function(e) {
13866 var n = e.file.name;
13867 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13868 var dot = n.lastIndexOf('.');
13869 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13870 });
13871 var kept = codeEntries.length;
13872 if (kept === 0) {
13873 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13874 if (btn) btn.disabled = false; return;
13875 }
13876
13877 function finish(tmpPath, sizes) {
13878 pathInput.value = tmpPath;
13879 scrollInputToEnd(pathInput);
13880 if (sizes) {
13881 window._lastUploadSizes = sizes;
13882 var sizeText = document.getElementById('project-size-text');
13883 var sizeBtn = document.getElementById('project-size-btn');
13884 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13885 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13886 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13887 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13888 }
13889 updateReportTitleFromPath();
13890 autoSetOutputDir(tmpPath);
13891 fetchProjectHistory(tmpPath);
13892 loadPreview();
13893 suggestCoverageFile(tmpPath);
13894 updateReview();
13895 if (btn) btn.disabled = false;
13896 }
13897
13898 if (typeof CompressionStream === 'undefined') {
13899 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13900 if (btn) btn.disabled = false; return;
13901 }
13902
13903 try {
13904 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13905 var BLOCK = 512;
13906 var cs = new CompressionStream('gzip');
13907 var wtr = cs.writable.getWriter();
13908 var chunks = [];
13909 var rdr = cs.readable.getReader();
13910 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13911
13912 function buildHdr(fp, sz) {
13913 var hdr = new Uint8Array(BLOCK);
13914 var enc = new TextEncoder();
13915 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]; }
13916 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13917 var nm = fp, pfx = '';
13918 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); } }
13919 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13920 for (var i = 148; i < 156; i++) hdr[i] = 32;
13921 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);
13922 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13923 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13924 return hdr;
13925 }
13926
13927 for (var i = 0; i < codeEntries.length; i++) {
13928 var ce = codeEntries[i];
13929 var buf = await ce.file.arrayBuffer();
13930 var data = new Uint8Array(buf);
13931 await wtr.write(buildHdr(ce.path, data.length));
13932 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13933 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13934 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13935 }
13936 await wtr.write(new Uint8Array(BLOCK * 2));
13937 await wtr.close();
13938 await collecting;
13939
13940 var blob = new Blob(chunks, { type: 'application/gzip' });
13941 var sizeMB = (blob.size / 1048576).toFixed(1);
13942 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13943 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13944 var d = await resp.json();
13945 if (d && d.tmp_path) {
13946 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13947 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13948 } catch (err) {
13949 showBannerToast('Upload failed: ' + String(err), true);
13950 if (btn) btn.disabled = false;
13951 }
13952 }).catch(function(err) {
13953 showBannerToast('Could not read folder: ' + String(err), true);
13954 if (btn) btn.disabled = false;
13955 });
13956 });
13957 }
13958 setupPathDropZone();
13959 if (browseCoverage) {
13960 browseCoverage.addEventListener("click", function () {
13961 pickDirectory(coverageInput || pathInput, "coverage");
13962 });
13963 }
13964
13965 function setCovStatus(state, opts) {
13966 if (!covScanStatus) return;
13967 opts = opts || {};
13968 covScanStatus.className = "cov-scan-status cov-scan-" + state;
13969 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13970 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>';
13971 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>';
13972 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>';
13973 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>';
13974 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13975 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13976 if (state === "scanning") {
13977 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13978 } else if (state === "found") {
13979 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13980 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13981 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13982 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13983 } else if (state === "hint") {
13984 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13985 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
13986 html += '<div class="cov-scan-sub">Generate one with:</div>';
13987 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13988 } else if (state === "none") {
13989 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13990 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
13991 }
13992 html += '</div></div>';
13993 covScanStatus.innerHTML = html;
13994 if (state === "found") {
13995 var useBtn = covScanStatus.querySelector(".cov-scan-use");
13996 if (useBtn) useBtn.addEventListener("click", function () {
13997 if (coverageInput) coverageInput.value = "";
13998 covAutoFilled = false;
13999 setCovStatus("idle");
14000 });
14001 }
14002 }
14003
14004 function suggestCoverageFile(projectPath) {
14005 if (!coverageInput || !covScanStatus) return;
14006 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14007 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
14008 clearTimeout(coverageSuggestTimer);
14009 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
14010 setCovStatus("scanning");
14011 coverageSuggestTimer = setTimeout(function () {
14012 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
14013 .then(function (r) { return r.json(); })
14014 .then(function (d) {
14015 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14016 if (!d) { setCovStatus("none"); return; }
14017 if (d.found) {
14018 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
14019 setCovStatus("found", { found: d.found, tool: d.tool });
14020 } else if (d.tool && d.hint) {
14021 setCovStatus("hint", { tool: d.tool, hint: d.hint });
14022 } else {
14023 setCovStatus("none");
14024 }
14025 })
14026 .catch(function () { setCovStatus("idle"); });
14027 }, 600);
14028 }
14029
14030 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
14031
14032 if (coverageInput) coverageInput.addEventListener("input", function () {
14033 covAutoFilled = false;
14034 if (!this.value.trim()) setCovStatus("idle");
14035 });
14036
14037 // ── Language pill overflow: collapse to "+N more" chip ─────────────
14038 function collapseLanguagePills() {
14039 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
14040 rows.forEach(function(row) {
14041 // Remove any previous overflow chip
14042 var prev = row.querySelector('.lang-overflow-chip');
14043 if (prev) prev.remove();
14044 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
14045 pills.forEach(function(p) { p.style.display = ''; });
14046 if (!pills.length) return;
14047
14048 // Measure after restoring all pills
14049 var containerRight = row.getBoundingClientRect().right;
14050 var hidden = [];
14051 for (var i = pills.length - 1; i >= 1; i--) {
14052 var rect = pills[i].getBoundingClientRect();
14053 if (rect.right > containerRight + 2) {
14054 hidden.unshift(pills[i]);
14055 pills[i].style.display = 'none';
14056 } else {
14057 break;
14058 }
14059 }
14060
14061 if (hidden.length) {
14062 var chip = document.createElement('button');
14063 chip.type = 'button';
14064 chip.className = 'language-pill lang-overflow-chip';
14065 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
14066 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
14067 row.appendChild(chip);
14068 }
14069 });
14070 }
14071
14072 // Run after preview loads (preview panel populates language pills)
14073 var _origLoadPreviewCb = window.__previewLoaded;
14074 document.addEventListener('previewLoaded', collapseLanguagePills);
14075 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
14076 setTimeout(collapseLanguagePills, 400);
14077
14078 // ── Project history & output dir auto-set ──────────────────────────
14079 var wsOutputRoot = document.getElementById("ws-output-root");
14080 var wsScanCount = document.getElementById("ws-scan-count");
14081 var wsLastScan = document.getElementById("ws-last-scan");
14082 var historyBadge = document.getElementById("path-history-badge");
14083 var historyTimer = null;
14084
14085 var wsOutputLink = document.getElementById("ws-output-link");
14086 function syncStripOutputRoot() {
14087 var val = outputDirInput ? outputDirInput.value : "";
14088 var display = val || "project/sloc";
14089 if (wsOutputRoot) wsOutputRoot.textContent = display;
14090 if (wsOutputLink) wsOutputLink.dataset.folder = val;
14091 }
14092
14093 function scrollInputToEnd(input) {
14094 if (!input) return;
14095 // Defer so the DOM has the new value before we measure scroll width.
14096 requestAnimationFrame(function () {
14097 input.scrollLeft = input.scrollWidth;
14098 input.selectionStart = input.selectionEnd = input.value.length;
14099 });
14100 }
14101
14102 function autoSetOutputDir(projectPath) {
14103 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
14104 if (GIT_MODE && GIT_OUTPUT_DIR) {
14105 outputDirInput.value = GIT_OUTPUT_DIR;
14106 scrollInputToEnd(outputDirInput);
14107 syncStripOutputRoot();
14108 updateReview();
14109 return;
14110 }
14111 if (!projectPath || !projectPath.trim()) return;
14112 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
14113 outputDirInput.value = cleaned + "/sloc";
14114 scrollInputToEnd(outputDirInput);
14115 syncStripOutputRoot();
14116 updateReview();
14117 }
14118
14119 var wsBranch = document.getElementById("ws-branch");
14120
14121 function fetchProjectHistory(projectPath) {
14122 if (!projectPath || !projectPath.trim()) {
14123 if (wsScanCount) wsScanCount.textContent = "—";
14124 if (wsLastScan) wsLastScan.textContent = "—";
14125 if (wsBranch) wsBranch.textContent = "—";
14126 if (historyBadge) historyBadge.style.display = "none";
14127 return;
14128 }
14129 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
14130 .then(function (r) { return r.ok ? r.json() : null; })
14131 .then(function (data) {
14132 if (!data) return;
14133 var countStr = data.scan_count > 0
14134 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
14135 : "never";
14136 var tsStr = data.last_scan_timestamp
14137 ? data.last_scan_timestamp.replace(" UTC","")
14138 : "—";
14139 if (wsScanCount) wsScanCount.textContent = countStr;
14140 if (wsLastScan) wsLastScan.textContent = tsStr;
14141 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
14142 if (data.scan_count > 0) {
14143 if (historyBadge) {
14144 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
14145 historyBadge.textContent = data.scan_count + " previous scan" +
14146 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
14147 "Last: " + (data.last_scan_timestamp || "—") +
14148 " — " + (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.";
14149 historyBadge.className = "path-history-badge found";
14150 historyBadge.style.display = "";
14151 }
14152 } else {
14153 if (historyBadge) historyBadge.style.display = "none";
14154 }
14155 })
14156 .catch(function () {});
14157 }
14158
14159 function onPathChange() {
14160 var val = pathInput ? pathInput.value : "";
14161 // Discard stale upload sizes when the user edits the path manually.
14162 window._lastUploadSizes = null;
14163 updateReportTitleFromPath();
14164 autoSetOutputDir(val);
14165 updateSidebarSummary();
14166 clearTimeout(historyTimer);
14167 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
14168 if (previewTimer) clearTimeout(previewTimer);
14169 previewTimer = setTimeout(loadPreview, 280);
14170 suggestCoverageFile(val);
14171 }
14172
14173 if (pathInput) {
14174 pathInput.addEventListener("input", onPathChange);
14175 }
14176
14177 if (outputDirInput) {
14178 outputDirInput.addEventListener("input", function () {
14179 outputDirInput.dataset.userEdited = "1";
14180 syncStripOutputRoot();
14181 updateReview();
14182 });
14183 }
14184
14185 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
14186 if (!node) return;
14187 node.addEventListener("input", function () {
14188 updateReview();
14189 if (previewTimer) clearTimeout(previewTimer);
14190 previewTimer = setTimeout(loadPreview, 280);
14191 });
14192 });
14193
14194 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
14195 var node = document.getElementById(id);
14196 if (node) node.addEventListener("change", updateReview);
14197 });
14198
14199 if (reportTitleInput) {
14200 reportTitleInput.addEventListener("input", function () {
14201 reportTitleTouched = reportTitleInput.value.trim().length > 0;
14202 updateReportTitleFromPath();
14203 updateReview();
14204 });
14205 }
14206
14207 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
14208 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
14209 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
14210 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
14211
14212 artifactCards.forEach(function (card) {
14213 card.addEventListener("click", function () {
14214 if (card.classList.contains("artifact-locked")) return;
14215 toggleArtifactCard(card);
14216 updateReview();
14217 });
14218 });
14219
14220 if (coverageInput) {
14221 coverageInput.addEventListener("input", function () {
14222 if (coverageInput.value.trim()) setCovStatus("idle");
14223 });
14224 }
14225
14226 if (form && loading && submitButton) {
14227 form.addEventListener("submit", function (e) {
14228 e.preventDefault();
14229 submitButton.disabled = true;
14230 submitButton.textContent = "Scanning...";
14231 startAsyncAnalysis(new FormData(form));
14232 });
14233 }
14234
14235 function openPath(folder) {
14236 if (!folder) return;
14237 fetch('/open-path?path=' + encodeURIComponent(folder))
14238 .then(function (r) { return r.json(); })
14239 .then(function (d) {
14240 if (d && d.server_mode_disabled)
14241 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
14242 })
14243 .catch(function () {});
14244 }
14245
14246 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14247 btn.addEventListener('click', function () {
14248 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
14249 });
14250 });
14251
14252 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
14253 if (wsOutputLink) {
14254 wsOutputLink.addEventListener('click', function () {
14255 openPath(wsOutputLink.dataset.folder || '');
14256 });
14257 }
14258
14259 loadSavedTheme();
14260 updateMixedPolicyUI();
14261 updatePythonDocstringUI();
14262 applyScanPreset();
14263 updatePresetDescriptions();
14264 applyArtifactPreset();
14265 updateReview();
14266 updateScrollProgress(); // initialise bar to 0% (step 1)
14267 window.addEventListener("scroll", updateScrollProgress, { passive: true });
14268 onPathChange(); // seed output dir, history badge, and preview from initial path
14269 loadPreview();
14270 updateStepNav(1);
14271
14272 // Restore step from URL hash on initial load (e.g., back-forward cache)
14273 (function() {
14274 var hashMatch = location.hash.match(/^#step([1-4])$/);
14275 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
14276 })();
14277
14278 (function randomizeWatermarks() {
14279 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14280 if (!wms.length) return;
14281 var placed = [];
14282 function tooClose(top, left) {
14283 for (var i = 0; i < placed.length; i++) {
14284 var dt = Math.abs(placed[i][0] - top);
14285 var dl = Math.abs(placed[i][1] - left);
14286 if (dt < 16 && dl < 12) return true;
14287 }
14288 return false;
14289 }
14290 function pick(leftBand) {
14291 for (var attempt = 0; attempt < 50; attempt++) {
14292 var top = Math.random() * 88 + 2;
14293 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14294 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14295 }
14296 var top = Math.random() * 88 + 2;
14297 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14298 placed.push([top, left]);
14299 return [top, left];
14300 }
14301 var half = Math.floor(wms.length / 2);
14302 wms.forEach(function (img, i) {
14303 var pos = pick(i < half);
14304 var size = Math.floor(Math.random() * 80 + 110);
14305 var rot = (Math.random() * 360).toFixed(1);
14306 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
14307 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;
14308 });
14309 })();
14310
14311 (function spawnCodeParticles() {
14312 var container = document.getElementById('code-particles');
14313 if (!container) return;
14314 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'];
14315 for (var i = 0; i < 38; i++) {
14316 (function(idx) {
14317 var el = document.createElement('span');
14318 el.className = 'code-particle';
14319 el.textContent = snippets[idx % snippets.length];
14320 var left = Math.random() * 94 + 2;
14321 var top = Math.random() * 88 + 6;
14322 var dur = (Math.random() * 10 + 9).toFixed(1);
14323 var delay = (Math.random() * 18).toFixed(1);
14324 var rot = (Math.random() * 26 - 13).toFixed(1);
14325 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14326 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';
14327 container.appendChild(el);
14328 })(i);
14329 }
14330 })();
14331 })();
14332 </script>
14333 <script nonce="{{ csp_nonce }}">
14334 (function () {
14335 var raw = {{ prefill_json|safe }};
14336 if (!raw || typeof raw !== 'object' || !raw.path) return;
14337 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
14338 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
14339 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
14340 setVal('path-input', raw.path || '');
14341 setVal('include-globs', raw.include_globs || '');
14342 setVal('exclude-globs', raw.exclude_globs || '');
14343 setVal('output-dir', raw.output_dir || '');
14344 setVal('report-title', raw.report_title || '');
14345 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
14346 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
14347 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
14348 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
14349 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
14350 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
14351 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
14352 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
14353 setChecked('generate-html', raw.generate_html !== false);
14354 setChecked('generate-pdf', !!raw.generate_pdf);
14355 // Trigger dynamic UI updates after pre-fill.
14356 setTimeout(function () {
14357 var pathEl = document.getElementById('path-input');
14358 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
14359 var policyEl = document.getElementById('mixed-line-policy');
14360 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
14361 }, 80);
14362 })();
14363 </script>
14364 <script nonce="{{ csp_nonce }}">
14365 (function(){
14366 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'}];
14367 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);});}
14368 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14369 function init(){
14370 var btn=document.getElementById('settings-btn');if(!btn)return;
14371 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14372 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>';
14373 document.body.appendChild(m);
14374 var g=document.getElementById('scheme-grid');
14375 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);});
14376 var cl=document.getElementById('settings-close');
14377 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);
14378 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');});
14379 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14380 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14381 }
14382 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14383 }());
14384 </script>
14385 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14386 <div class="wb-ftip-arrow"></div>
14387 <span id="wb-ftip-text"></span>
14388 </div>
14389 <script nonce="{{ csp_nonce }}">(function(){
14390 var tip=document.getElementById('wb-ftip');
14391 var txt=document.getElementById('wb-ftip-text');
14392 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14393 if(!tip||!txt)return;
14394 function pos(el){
14395 var r=el.getBoundingClientRect();
14396 tip.style.display='block';
14397 var tw=tip.offsetWidth;
14398 var lx=r.left+r.width/2-tw/2;
14399 if(lx<8)lx=8;
14400 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14401 tip.style.left=lx+'px';
14402 tip.style.top=(r.bottom+8)+'px';
14403 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';}
14404 }
14405 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14406 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14407 el.addEventListener('mouseleave',function(){tip.style.display='none';});
14408 });
14409 })();
14410 (function(){
14411 function fixArtifactHintSpacing(){
14412 var grid=document.querySelector('.artifact-grid');
14413 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14414 }
14415 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14416 }());
14417 (function(){
14418 var dot=document.getElementById('status-dot');
14419 var pingEl=document.getElementById('server-ping-ms');
14420 var tipEl=document.getElementById('server-tip-ping');
14421 var fm=document.getElementById('footer-mode');
14422 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)';}}
14423 function doPing(){
14424 var t0=performance.now();
14425 fetch('/healthz',{cache:'no-store'})
14426 .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);})
14427 .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)';}});
14428 }
14429 doPing();
14430 setInterval(doPing,5000);
14431 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');}
14432 })();
14433 </script>
14434 <footer class="site-footer">
14435 local code analysis - metrics, history and reports
14436 · <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>
14437 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14438 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14439 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14440 · <a href="/api-docs" rel="noopener">REST API</a>
14441 </footer>
14442</body>
14443</html>
14444"##,
14445 ext = "html"
14446)]
14447struct IndexTemplate {
14448 version: &'static str,
14449 prefill_json: String,
14450 csp_nonce: String,
14451 git_repo: String,
14452 git_ref: String,
14453 git_label_json: String,
14454 git_output_dir_json: String,
14455 server_mode: bool,
14456}
14457
14458#[derive(Template)]
14461#[template(
14462 source = r##"
14463<!doctype html>
14464<html lang="en">
14465<head>
14466 <meta charset="utf-8">
14467 <meta name="viewport" content="width=device-width, initial-scale=1">
14468 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14469 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14470 <style nonce="{{ csp_nonce }}">
14471 :root {
14472 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14473 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14474 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14475 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14476 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14477 }
14478 body.dark-theme {
14479 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14480 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14481 }
14482 *{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;}
14483 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14484 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14485 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14486 .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;}
14487 @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));}}
14488 .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);}
14489 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14490 .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));}
14491 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14492 .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;}
14493 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14494 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14495 @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; } }
14496 .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;}
14497 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14498 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14499 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14500 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14501 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14502 .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;}
14503 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14504 .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);}
14505 .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;}
14506 .settings-close:hover{color:var(--text);background:var(--surface-2);}
14507 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14508 .settings-modal-body{padding:14px 16px 16px;}
14509 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14510 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14511 .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;}
14512 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14513 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14514 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14515 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14516 .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;}
14517 .tz-select:focus{border-color:var(--oxide);}
14518 .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;}
14519 .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;}
14520 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14521 .hero{text-align:center;margin:0 auto 18px;}
14522 .hero-logo-wrap{display:inline-block;cursor:default;}
14523 .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;}
14524 .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;}
14525 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14526 .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;}
14527 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%);}
14528 .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;
14529 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14530 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14531 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;}
14532 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14533 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14534 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;}
14535 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14536 .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;}
14537 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14538 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14539 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14540 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14541 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14542 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14543 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14544 .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;}
14545 .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;}
14546 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14547 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14548 .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);}
14549 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14550 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14551 .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);}
14552 .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);}
14553 .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);}
14554 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14555 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14556 .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;}
14557 body.dark-theme .action-card-cta{color:var(--oxide);}
14558 .action-card.view .action-card-cta{color:var(--accent-2);}
14559 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14560 .action-card.compare .action-card-cta{color:#7c3aed;}
14561 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14562 .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);}
14563 .action-card.git-tools .action-card-cta{color:#15803d;}
14564 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14565 .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);}
14566 .action-card.trend .action-card-cta{color:#0e7490;}
14567 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14568 .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);}
14569 .action-card.automation .action-card-cta{color:#b45309;}
14570 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14571 .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);}
14572 .action-card.test-metrics .action-card-cta{color:#be185d;}
14573 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14574 .action-card:hover .action-card-cta{gap:12px;}
14575 .action-card.card-split{flex-direction:row;align-items:stretch;}
14576 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14577 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14578 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14579 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14580 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14581 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14582 .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;}
14583 .ac-badge.active{opacity:1;}
14584 .ac-badge.github{border-color:#555;color:#555;}
14585 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14586 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14587 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14588 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14589 body.dark-theme .ac-right-row{color:var(--muted);}
14590 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14591 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14592 .divider{height:1px;background:var(--line);margin:32px 0;}
14593 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14594 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14595 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14596 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14597 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14598 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14599 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14600 body.dark-theme .info-chip-val{color:var(--oxide);}
14601 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14602 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14603 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14604 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14605 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14606 border:6px solid transparent;border-top-color:var(--text);}
14607 .info-chip:hover .info-chip-tip{display:block;}
14608 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14609 .chip-slide.fading{filter:blur(5px);opacity:0;}
14610 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14611 .site-footer a{color:var(--muted);}
14612 .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;}
14613 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14614 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14615 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14616 .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;}
14617 .lan-badge.local{background:var(--oxide-2);}
14618 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14619 .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);}
14620 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14621 .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;}
14622 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14623 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14624 .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;}
14625 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14626 .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;}
14627 .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);}
14628 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14629 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14630 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14631 .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;}
14632 @media (max-height: 1100px) {
14633 .page{padding-top:10px;}
14634 .hero{margin-bottom:10px;}
14635 .hero-logo{width:54px;height:60px;}
14636 .hero-logo-shadow{width:42px;}
14637 .hero-title{font-size:28px;}
14638 .hero-subtitle{font-size:13px;}
14639 .card-sections{gap:16px;margin-bottom:10px;}
14640 .card-section-grid-2,.card-section-grid-3{gap:10px;}
14641 .action-card{padding:8px 15px 8px;}
14642 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14643 .action-card-icon svg{width:18px;height:18px;}
14644 .action-card-title{font-size:13px;}
14645 .action-card-desc{font-size:11px;margin-bottom:6px;}
14646 .action-card-cta{font-size:11px;}
14647 .ac-right-row{font-size:11px;}
14648 .divider{margin:14px 0;}
14649 .info-strip{gap:7px;margin-bottom:12px;}
14650 .info-chip{padding:7px 10px;}
14651 .info-chip-val{font-size:13px;}
14652 .info-chip-label{font-size:9px;}
14653 .site-footer{padding:8px 24px;font-size:12px;}
14654 }
14655 @media (max-height: 850px) {
14656 .page{padding-top:6px;}
14657 .hero{margin-bottom:6px;}
14658 .hero-logo{width:42px;height:46px;}
14659 .hero-title{font-size:22px;}
14660 .hero-subtitle{font-size:12px;}
14661 .card-sections{gap:10px;}
14662 .action-card-desc{margin-bottom:4px;}
14663 .divider{margin:8px 0;}
14664 .info-strip{margin-bottom:6px;}
14665 .lan-local-hint{margin-top:10px;}
14666 }
14667 </style>
14668</head>
14669<body>
14670 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14678 </div>
14679 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14680 <div class="top-nav">
14681 <div class="top-nav-inner">
14682 <a class="brand" href="/">
14683 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14684 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14685 </a>
14686 <div class="nav-right">
14687 <a class="nav-pill" href="/">Home</a>
14688 <div class="nav-dropdown">
14689 <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>
14690 <div class="nav-dropdown-menu">
14691 <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>
14692 </div>
14693 </div>
14694 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14695 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14696 <div class="nav-dropdown">
14697 <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>
14698 <div class="nav-dropdown-menu">
14699 <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>
14700 </div>
14701 </div>
14702 <div class="server-status-wrap" id="server-status-wrap">
14703 <div class="nav-pill server-online-pill" id="server-status-pill">
14704 <span class="status-dot" id="status-dot"></span>
14705 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14706 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14707 </div>
14708 <div class="server-status-tip">
14709 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14710 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14711 </div>
14712 </div>
14713 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14714 <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>
14715 </button>
14716 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14717 <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>
14718 <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>
14719 </button>
14720 </div>
14721 </div>
14722 </div>
14723
14724 <div class="page">
14725 <div class="hero">
14726 <div class="hero-logo-wrap" id="hero-logo-wrap">
14727 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14728 </div>
14729 <div class="hero-logo-shadow"></div>
14730 <div class="hero-title-wrap">
14731 <div class="hero-title-aura" aria-hidden="true"></div>
14732 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14733 </div>
14734 <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>
14735 </div>
14736
14737 <div class="card-sections">
14738
14739 <div>
14740 <div class="card-section-label">Analysis</div>
14741 <div class="card-section-grid-2">
14742 <a class="action-card scan card-split" href="/scan-setup">
14743 <div class="action-card-left">
14744 <div class="action-card-icon">
14745 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14746 </div>
14747 <div class="action-card-title">Scan Project</div>
14748 <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>
14749 <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>
14750 </div>
14751 <div class="action-card-sep"></div>
14752 <div class="action-card-right">
14753 <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>
14754 <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>
14755 <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>
14756 <div class="ac-right-stat" id="acp-scan-stat"></div>
14757 </div>
14758 </a>
14759 <a class="action-card test-metrics card-split" href="/test-metrics">
14760 <div class="action-card-left">
14761 <div class="action-card-icon">
14762 <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>
14763 </div>
14764 <div class="action-card-title">Test Metrics</div>
14765 <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>
14766 <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>
14767 </div>
14768 <div class="action-card-sep"></div>
14769 <div class="action-card-right">
14770 <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>
14771 <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>
14772 <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>
14773 <div class="ac-right-stat" id="acp-test-stat"></div>
14774 </div>
14775 </a>
14776 </div>
14777 </div>
14778
14779 <div>
14780 <div class="card-section-label">Reports & Insights</div>
14781 <div class="card-section-grid-3">
14782 <a class="action-card view" href="/view-reports">
14783 <div class="action-card-icon">
14784 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14785 </div>
14786 <div class="action-card-title">View Reports</div>
14787 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14788 <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>
14789 </a>
14790 <a class="action-card compare" href="/compare-scans">
14791 <div class="action-card-icon">
14792 <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>
14793 </div>
14794 <div class="action-card-title">Compare Scans</div>
14795 <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>
14796 <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>
14797 </a>
14798 <a class="action-card trend" href="/trend-reports">
14799 <div class="action-card-icon">
14800 <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>
14801 </div>
14802 <div class="action-card-title">Trend Report</div>
14803 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14804 <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>
14805 </a>
14806 </div>
14807 </div>
14808
14809 <div>
14810 <div class="card-section-label">Developer Tools</div>
14811 <div class="card-section-grid-2">
14812 <a class="action-card git-tools card-split" href="/git-browser">
14813 <div class="action-card-left">
14814 <div class="action-card-icon">
14815 <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>
14816 </div>
14817 <div class="action-card-title">Git Browser</div>
14818 <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>
14819 <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>
14820 </div>
14821 <div class="action-card-sep"></div>
14822 <div class="action-card-right">
14823 <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>
14824 <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>
14825 <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>
14826 </div>
14827 </a>
14828 <a class="action-card automation card-split" href="/integrations">
14829 <div class="action-card-left">
14830 <div class="action-card-icon">
14831 <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>
14832 </div>
14833 <div class="action-card-title">Integrations</div>
14834 <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>
14835 <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>
14836 </div>
14837 <div class="action-card-sep"></div>
14838 <div class="action-card-right">
14839 <div class="ac-badges-grid">
14840 <span class="ac-badge github" id="acp-gh">GitHub</span>
14841 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
14842 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
14843 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14844 </div>
14845 <div class="ac-right-stat" id="acp-int-stat"></div>
14846 </div>
14847 </a>
14848 </div>
14849 </div>
14850
14851 </div>
14852
14853 {% if server_mode %}
14854 <div class="lan-card server">
14855 <div class="lan-card-header">
14856 <span class="lan-badge">LAN server</span>
14857 Accessible on your network
14858 </div>
14859 {% if let Some(ip) = lan_ip %}
14860 <div class="lan-url-row">
14861 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14862 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14863 <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>
14864 Copy URL
14865 </button>
14866 </div>
14867 <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>
14868 {% if has_api_key %}
14869 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
14870 {% endif %}
14871 {% else %}
14872 <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>
14873 {% endif %}
14874 </div>
14875 {% endif %}
14876
14877 <div class="divider"></div>
14878
14879 <div class="info-strip">
14880 <div class="info-chip">
14881 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14882 <div class="chip-slide">
14883 <div class="info-chip-val">41</div>
14884 <div class="info-chip-label">Languages</div>
14885 </div>
14886 </div>
14887 <div class="info-chip">
14888 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14889 <div class="chip-slide">
14890 <div class="info-chip-val">100%</div>
14891 <div class="info-chip-label">Self-contained</div>
14892 </div>
14893 </div>
14894 <div class="info-chip">
14895 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14896 <div class="chip-slide">
14897 <div class="info-chip-val">HTML+PDF</div>
14898 <div class="info-chip-label">Exportable reports</div>
14899 </div>
14900 </div>
14901 <div class="info-chip">
14902 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14903 <div class="chip-slide">
14904 <div class="info-chip-val">Webhook</div>
14905 <div class="info-chip-label">3 platforms</div>
14906 </div>
14907 </div>
14908 <div class="info-chip">
14909 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14910 <div class="chip-slide">
14911 <div class="info-chip-val">IEEE</div>
14912 <div class="info-chip-label">1045-1992</div>
14913 </div>
14914 </div>
14915 </div>
14916
14917 {% if lan_ip.is_none() %}
14918 <div class="lan-local-hint">
14919 <strong>Want teammates on the same network to access this?</strong><br>
14920 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
14921 </div>
14922 {% endif %}
14923 </div>
14924
14925 <footer class="site-footer">
14926 local code analysis - metrics, history and reports
14927 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
14928 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14929 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14930 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14931 · <a href="/api-docs" rel="noopener">REST API</a>
14932 </footer>
14933
14934 <script nonce="{{ csp_nonce }}">
14935 (function () {
14936 var storageKey = 'oxide-sloc-theme';
14937 var body = document.body;
14938 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14939 var toggle = document.getElementById('theme-toggle');
14940 if (toggle) toggle.addEventListener('click', function () {
14941 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14942 body.classList.toggle('dark-theme', next === 'dark');
14943 try { localStorage.setItem(storageKey, next); } catch(e) {}
14944 });
14945 var copyBtn = document.getElementById('lan-copy-btn');
14946 if (copyBtn) copyBtn.addEventListener('click', function() {
14947 var btn = this;
14948 var el = document.getElementById('lan-url-val');
14949 if (!el) return;
14950 var url = el.textContent.trim();
14951 if (navigator.clipboard) {
14952 navigator.clipboard.writeText(url).then(function() {
14953 var orig = btn.innerHTML;
14954 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!';
14955 setTimeout(function() { btn.innerHTML = orig; }, 1800);
14956 });
14957 }
14958 });
14959 (function randomizeWatermarks() {
14960 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14961 if (!wms.length) return;
14962 var placed = [];
14963 function tooClose(top, left) {
14964 for (var i = 0; i < placed.length; i++) {
14965 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14966 if (dt < 16 && dl < 12) return true;
14967 }
14968 return false;
14969 }
14970 function pick(leftBand) {
14971 for (var attempt = 0; attempt < 50; attempt++) {
14972 var top = Math.random() * 88 + 2;
14973 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14974 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14975 }
14976 var top = Math.random() * 88 + 2;
14977 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14978 placed.push([top, left]); return [top, left];
14979 }
14980 var half = Math.floor(wms.length / 2);
14981 wms.forEach(function (img, i) {
14982 var pos = pick(i < half);
14983 var size = Math.floor(Math.random() * 100 + 120);
14984 var rot = (Math.random() * 360).toFixed(1);
14985 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14986 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;
14987 });
14988 })();
14989
14990 (function spawnCodeParticles() {
14991 var container = document.getElementById('code-particles');
14992 if (!container) return;
14993 var snippets = [
14994 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14995 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14996 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14997 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14998 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14999 ];
15000 var count = 38;
15001 for (var i = 0; i < count; i++) {
15002 (function(idx) {
15003 var el = document.createElement('span');
15004 el.className = 'code-particle';
15005 var text = snippets[idx % snippets.length];
15006 el.textContent = text;
15007 var left = Math.random() * 94 + 2;
15008 var top = Math.random() * 88 + 6;
15009 var dur = (Math.random() * 10 + 9).toFixed(1);
15010 var delay = (Math.random() * 18).toFixed(1);
15011 var rot = (Math.random() * 26 - 13).toFixed(1);
15012 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15013 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
15014 + '--rot:' + rot + 'deg;--op:' + op + ';'
15015 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
15016 container.appendChild(el);
15017 })(i);
15018 }
15019 })();
15020 (function heroAnimations() {
15021 var sub = document.getElementById('hero-subtitle');
15022 if (sub) {
15023 var full = sub.textContent.trim();
15024 sub.textContent = '';
15025 sub.style.opacity = '1';
15026 var cursor = document.createElement('span');
15027 cursor.className = 'hero-cursor';
15028 sub.appendChild(cursor);
15029 var i = 0;
15030 setTimeout(function() {
15031 var iv = setInterval(function() {
15032 if (i < full.length) {
15033 sub.insertBefore(document.createTextNode(full[i]), cursor);
15034 i++;
15035 } else {
15036 clearInterval(iv);
15037 setTimeout(function() {
15038 cursor.style.transition = 'opacity 1s ease';
15039 cursor.style.opacity = '0';
15040 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
15041 }, 2400);
15042 }
15043 }, 11);
15044 }, 374);
15045 }
15046 })();
15047 (function logoBob() {
15048 var logo = document.querySelector('.hero-logo');
15049 var shadow = document.querySelector('.hero-logo-shadow');
15050 if (!logo) return;
15051 var cycleStart = null, cycleDur = 3600;
15052 var peakY = -14, peakScale = 1.07, peakRot = 0;
15053 function newCycle() {
15054 cycleDur = 3000 + Math.random() * 1840;
15055 peakY = -(9 + Math.random() * 13.8);
15056 peakScale = 1.04 + Math.random() * 0.081;
15057 peakRot = (Math.random() * 11.5 - 5.75);
15058 }
15059 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
15060 newCycle();
15061 function frame(ts) {
15062 if (cycleStart === null) cycleStart = ts;
15063 var t = (ts - cycleStart) / cycleDur;
15064 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
15065 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
15066 var y = peakY * phase;
15067 var sc = 1 + (peakScale - 1) * phase;
15068 var rot = peakRot * Math.sin(Math.PI * phase);
15069 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
15070 if (shadow) {
15071 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
15072 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
15073 }
15074 requestAnimationFrame(frame);
15075 }
15076 requestAnimationFrame(frame);
15077 })();
15078 (function mouseEffects() {
15079 var heroTitle = document.getElementById('hero-title');
15080 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
15081 function tick() {
15082 raf = null;
15083 if (heroTitle) {
15084 var r = heroTitle.getBoundingClientRect();
15085 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
15086 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
15087 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
15088 }
15089 }
15090 document.addEventListener('mousemove', function(e) {
15091 mx = e.clientX; my = e.clientY;
15092 if (!raf) raf = requestAnimationFrame(tick);
15093 });
15094 document.addEventListener('mouseleave', function() {
15095 if (heroTitle) {
15096 heroTitle.style.transition = 'transform 0.5s ease';
15097 heroTitle.style.transform = '';
15098 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
15099 }
15100 });
15101 document.querySelectorAll('.action-card').forEach(function(card) {
15102 card.addEventListener('mousemove', function(e) {
15103 var rect = card.getBoundingClientRect();
15104 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
15105 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
15106 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
15107 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
15108 });
15109 card.addEventListener('mouseleave', function() {
15110 card.style.transition = '';
15111 card.style.transform = '';
15112 });
15113 });
15114 })();
15115 (function chipSlideshow() {
15116 var slides = [
15117 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
15118 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
15119 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
15120 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
15121 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
15122 ];
15123 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
15124 var indices = [0,0,0,0,0];
15125 var paused = [false,false,false,false,false];
15126 chips.forEach(function(chip, i) {
15127 chip.addEventListener('mouseenter', function() { paused[i] = true; });
15128 chip.addEventListener('mouseleave', function() { paused[i] = false; });
15129 });
15130 function advance(i) {
15131 if (paused[i]) return;
15132 var chip = chips[i];
15133 var inner = chip.querySelector('.chip-slide');
15134 if (!inner) return;
15135 inner.classList.add('fading');
15136 setTimeout(function() {
15137 indices[i] = (indices[i] + 1) % slides[i].length;
15138 var s = slides[i][indices[i]];
15139 chip.querySelector('.info-chip-val').textContent = s.v;
15140 chip.querySelector('.info-chip-label').textContent = s.l;
15141 inner.classList.remove('fading');
15142 }, 720);
15143 }
15144 setInterval(function() {
15145 chips.forEach(function(chip, i) { advance(i); });
15146 }, 6000);
15147 })();
15148 (function cardLiveData() {
15149 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
15150 var el = document.getElementById('acp-scan-stat');
15151 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
15152 }).catch(function(){});
15153 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
15154 var el = document.getElementById('acp-test-stat');
15155 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
15156 }).catch(function(){});
15157 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
15158 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
15159 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
15160 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
15161 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
15162 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
15163 var stat = document.getElementById('acp-int-stat');
15164 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
15165 }).catch(function(){});
15166 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
15167 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
15168 }).catch(function(){});
15169 })();
15170 })();
15171 </script>
15172 <script nonce="{{ csp_nonce }}">
15173 (function(){
15174 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'}];
15175 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);});}
15176 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15177 function init(){
15178 var btn=document.getElementById('settings-btn');if(!btn)return;
15179 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15180 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>';
15181 document.body.appendChild(m);
15182 var g=document.getElementById('scheme-grid');
15183 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);});
15184 var cl=document.getElementById('settings-close');
15185 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);
15186 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');});
15187 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15188 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15189 }
15190 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15191 }());
15192 </script>
15193 <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>
15194</body>
15195</html>
15196"##,
15197 ext = "html"
15198)]
15199struct SplashTemplate {
15200 csp_nonce: String,
15201 server_mode: bool,
15202 lan_ip: Option<String>,
15203 port: u16,
15204 version: &'static str,
15205 has_api_key: bool,
15206}
15207
15208#[derive(Template)]
15211#[template(
15212 source = r##"
15213<!doctype html>
15214<html lang="en">
15215<head>
15216 <meta charset="utf-8">
15217 <meta name="viewport" content="width=device-width, initial-scale=1">
15218 <title>OxideSLOC — Start a Scan</title>
15219 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15220 <style nonce="{{ csp_nonce }}">
15221 :root {
15222 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
15223 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15224 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15225 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15226 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
15227 }
15228 body.dark-theme {
15229 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
15230 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
15231 }
15232 *{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;}
15233 .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);}
15234 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15235 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
15236 .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));}
15237 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
15238 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
15239 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
15240 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15241 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15242 @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; } }
15243 .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;}
15244 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15245 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
15246 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15247 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15248 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15249 .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;}
15250 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15251 .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);}
15252 .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;}
15253 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15254 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15255 .settings-modal-body{padding:14px 16px 16px;}
15256 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15257 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15258 .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;}
15259 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15260 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15261 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15262 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15263 .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;}
15264 .tz-select:focus{border-color:var(--oxide);}
15265 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
15266 .page-header{text-align:center;margin-bottom:16px;}
15267 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
15268 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
15269 /* Cards */
15270 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
15271 .option-card-wrap{position:relative;}
15272 .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;}
15273 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
15274 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
15275 .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;}
15276 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
15277 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
15278 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
15279 .card-top-row{display:flex;align-items:center;gap:20px;}
15280 /* Two-column layout inside each card */
15281 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
15282 .card-left{display:flex;align-items:flex-start;min-width:0;}
15283 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
15284 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
15285 .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);}
15286 .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);}
15287 .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);}
15288 .card-text{min-width:0;}
15289 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
15290 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
15291 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
15292 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
15293 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
15294 /* Right CTA column */
15295 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
15296 .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;}
15297 /* Re-scan count badge */
15298 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
15299 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
15300 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
15301 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
15302 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
15303 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
15304 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
15305 body.dark-theme .btn-secondary{color:var(--oxide);}
15306 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
15307 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
15308 /* File input overlay — must be full-width so it aligns with other card-right buttons */
15309 .file-input-wrap{position:relative;width:100%;}
15310 .file-input-wrap .btn{width:100%;}
15311 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
15312 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15313 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15314 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15315 .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;}
15316 @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));}}
15317 /* Recent list (card 3 — full-width section below header) */
15318 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
15319 .recent-list{display:flex;flex-direction:column;gap:8px;}
15320 .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;}
15321 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
15322 .recent-item-info{flex:1;min-width:0;}
15323 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
15324 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
15325 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
15326 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
15327 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15328 .site-footer a{color:var(--muted);}
15329 @media(max-width:680px){
15330 .card-body{grid-template-columns:1fr;}
15331 .card-right{flex-direction:row;flex-wrap:wrap;}
15332 .btn{flex:1;}
15333 }
15334 .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;}
15335 .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;}
15336 .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;}
15337 </style>
15338</head>
15339<body>
15340 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15348 </div>
15349 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15350 <div class="top-nav">
15351 <div class="top-nav-inner">
15352 <a class="brand" href="/">
15353 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15354 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15355 </a>
15356 <div class="nav-right">
15357 <a class="nav-pill" href="/">Home</a>
15358 <div class="nav-dropdown">
15359 <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>
15360 <div class="nav-dropdown-menu">
15361 <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>
15362 </div>
15363 </div>
15364 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15365 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15366 <div class="nav-dropdown">
15367 <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>
15368 <div class="nav-dropdown-menu">
15369 <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>
15370 </div>
15371 </div>
15372 <div class="server-status-wrap" id="server-status-wrap">
15373 <div class="nav-pill server-online-pill" id="server-status-pill">
15374 <span class="status-dot" id="status-dot"></span>
15375 <span id="server-status-label">Server</span>
15376 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15377 </div>
15378 <div class="server-status-tip">
15379 OxideSLOC is running — accessible on your network.
15380 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15381 </div>
15382 </div>
15383 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15384 <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>
15385 </button>
15386 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15387 <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>
15388 <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>
15389 </button>
15390 </div>
15391 </div>
15392 </div>
15393
15394 <div class="page">
15395 <div class="page-header">
15396 <h1>How would you like to scan?</h1>
15397 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15398 </div>
15399
15400 <div class="option-grid">
15401
15402 <!-- Option 1: New scan -->
15403 <div class="option-card-wrap">
15404 <div class="option-card">
15405 <div class="option-icon new-scan">
15406 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15407 </div>
15408 <div class="card-body">
15409 <div class="card-left">
15410 <div class="card-text">
15411 <div class="option-title">Start a new scan</div>
15412 <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>
15413 <ul class="feature-list">
15414 <li>Live project scope preview before you run</li>
15415 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15416 <li>HTML, PDF, and JSON output — your choice</li>
15417 </ul>
15418 </div>
15419 </div>
15420 <div class="card-right">
15421 <a class="btn btn-primary" href="/scan">
15422 Configure & scan
15423 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15424 </a>
15425 <p class="card-tip">Full 4-step setup · all options</p>
15426 </div>
15427 </div>
15428 </div>
15429 </div>
15430
15431 <!-- Option 2: Load from config file -->
15432 <div class="option-card-wrap">
15433 <div class="option-card">
15434 <div class="option-icon load-config">
15435 <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>
15436 </div>
15437 <div class="card-body">
15438 <div class="card-left">
15439 <div class="card-text">
15440 <div class="option-title">Load a saved config</div>
15441 <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>
15442 <ul class="feature-list">
15443 <li>All 15 settings restored from the file</li>
15444 <li>Fully editable — change path or output dir</li>
15445 <li>Works with any scan-config.json</li>
15446 </ul>
15447 </div>
15448 </div>
15449 <div class="card-right">
15450 <div class="file-input-wrap">
15451 <button class="btn btn-secondary" id="load-config-btn" type="button">
15452 <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>
15453 Choose config file
15454 </button>
15455 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15456 </div>
15457 <p class="card-tip" id="config-file-name">Exported after every scan</p>
15458 </div>
15459 </div>
15460 </div>
15461 </div>
15462
15463 <!-- Option 3: Re-scan recent project -->
15464 <div class="option-card-wrap">
15465 <div class="option-card" id="recent-card">
15466 <div class="card-top-row">
15467 <div class="option-icon rescan">
15468 <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>
15469 </div>
15470 <div class="card-body">
15471 <div class="card-left">
15472 <div class="card-text">
15473 <div class="option-title">Re-scan a recent project</div>
15474 <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>
15475 <ul class="feature-list">
15476 <li>All 15+ settings restored from the saved config</li>
15477 <li>Path and output dir are editable before running</li>
15478 <li>Only scans with a saved config appear here</li>
15479 </ul>
15480 </div>
15481 </div>
15482 <div class="card-right">
15483 <div class="rescan-count-box">
15484 <div class="rescan-count-num" id="rescan-count-num">—</div>
15485 <div class="rescan-count-label">saved configs</div>
15486 </div>
15487 <a class="btn btn-secondary" href="/view-reports">
15488 <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>
15489 View all runs
15490 </a>
15491 <p class="card-tip">Opens run history</p>
15492 </div>
15493 </div>
15494 </div>
15495 <div class="section-divider"></div>
15496 <div class="recent-list" id="recent-list">
15497 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15498 </div>
15499 </div>
15500 </div>
15501
15502 </div>
15503 </div>
15504
15505 <footer class="site-footer">
15506 local code analysis - metrics, history and reports
15507 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
15508 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15509 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15510 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15511 · <a href="/api-docs" rel="noopener">REST API</a>
15512 </footer>
15513
15514 <script nonce="{{ csp_nonce }}">
15515 (function () {
15516 var storageKey = 'oxide-sloc-theme';
15517 var body = document.body;
15518 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15519 var toggle = document.getElementById('theme-toggle');
15520 if (toggle) toggle.addEventListener('click', function () {
15521 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15522 body.classList.toggle('dark-theme', next === 'dark');
15523 try { localStorage.setItem(storageKey, next); } catch(e) {}
15524 });
15525
15526 (function randomizeWatermarks() {
15527 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15528 if (!wms.length) return;
15529 var placed = [];
15530 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; }
15531 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]; }
15532 var half = Math.floor(wms.length / 2);
15533 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; });
15534 })();
15535 (function spawnCodeParticles() {
15536 var container = document.getElementById('code-particles');
15537 if (!container) return;
15538 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'];
15539 var count = 38;
15540 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); }
15541 })();
15542 // Recent scans data injected from server
15543 var recentScans = {{ recent_scans_json|safe }};
15544
15545 function configToParams(cfg) {
15546 var p = new URLSearchParams();
15547 p.set('prefilled', '1');
15548 if (cfg.path) p.set('path', cfg.path);
15549 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15550 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15551 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15552 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15553 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15554 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15555 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15556 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15557 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15558 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15559 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15560 if (cfg.report_title) p.set('report_title', cfg.report_title);
15561 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15562 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15563 return p;
15564 }
15565
15566 // Build recent scan list (capped at 3 visible entries)
15567 var list = document.getElementById('recent-list');
15568 var noNote = document.getElementById('no-recent-note');
15569 var hasAny = false;
15570 var MAX_RECENT = 3;
15571 if (Array.isArray(recentScans)) {
15572 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15573 var shown = 0;
15574 validEntries.forEach(function (entry) {
15575 if (shown >= MAX_RECENT) return;
15576 shown++;
15577 hasAny = true;
15578 var item = document.createElement('div');
15579 item.className = 'recent-item';
15580 item.title = 'Restore all settings and open wizard';
15581 item.innerHTML =
15582 '<div class="recent-item-info">' +
15583 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15584 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
15585 '</div>' +
15586 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15587 item.addEventListener('click', function () {
15588 var params = configToParams(entry.config);
15589 window.location.href = '/scan?' + params.toString();
15590 });
15591 list.appendChild(item);
15592 });
15593 if (validEntries.length > MAX_RECENT) {
15594 var moreEl = document.createElement('div');
15595 moreEl.className = 'recent-more-link';
15596 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
15597 list.appendChild(moreEl);
15598 }
15599 }
15600 if (hasAny && noNote) noNote.style.display = 'none';
15601 // Update count badge
15602 var countEl = document.getElementById('rescan-count-num');
15603 if (countEl) {
15604 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15605 countEl.textContent = total > 0 ? total : '0';
15606 }
15607
15608 // Config file loader
15609 var fileInput = document.getElementById('config-file-input');
15610 var fileName = document.getElementById('config-file-name');
15611 if (fileInput) {
15612 fileInput.addEventListener('change', function () {
15613 var file = fileInput.files && fileInput.files[0];
15614 if (!file) return;
15615 if (fileName) fileName.textContent = '✓ ' + file.name;
15616 var reader = new FileReader();
15617 reader.onload = function (e) {
15618 try {
15619 var cfg = JSON.parse(e.target.result);
15620 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15621 var params = configToParams(cfg);
15622 window.location.href = '/scan?' + params.toString();
15623 } catch (err) {
15624 alert('Could not parse config file: ' + err.message);
15625 }
15626 };
15627 reader.readAsText(file);
15628 });
15629 }
15630
15631 function escHtml(s) {
15632 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
15633 }
15634 })();
15635 </script>
15636 <script nonce="{{ csp_nonce }}">
15637 (function(){
15638 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'}];
15639 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);});}
15640 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15641 function init(){
15642 var btn=document.getElementById('settings-btn');if(!btn)return;
15643 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15644 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>';
15645 document.body.appendChild(m);
15646 var g=document.getElementById('scheme-grid');
15647 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);});
15648 var cl=document.getElementById('settings-close');
15649 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);
15650 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');});
15651 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15652 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15653 }
15654 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15655 }());
15656 </script>
15657 <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>
15658</body>
15659</html>
15660"##,
15661 ext = "html"
15662)]
15663struct ScanSetupTemplate {
15664 version: &'static str,
15665 recent_scans_json: String,
15666 csp_nonce: String,
15667}
15668
15669#[derive(Template)]
15670#[template(
15671 source = r##"
15672<!doctype html>
15673<html lang="en">
15674<head>
15675 <meta charset="utf-8">
15676 <meta name="viewport" content="width=device-width, initial-scale=1">
15677 <title>OxideSLOC | {{ report_title }} | Report</title>
15678 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15679 <style nonce="{{ csp_nonce }}">
15680 :root {
15681 --radius: 18px;
15682 --bg: #f5efe8;
15683 --surface: rgba(255,255,255,0.82);
15684 --surface-2: #fbf7f2;
15685 --surface-3: #efe6dc;
15686 --line: #e6d0bf;
15687 --line-strong: #dcb89f;
15688 --text: #43342d;
15689 --muted: #7b675b;
15690 --muted-2: #a08777;
15691 --nav: #b85d33;
15692 --nav-2: #7a371b;
15693 --accent: #6f9bff;
15694 --accent-2: #4a78ee;
15695 --oxide: #d37a4c;
15696 --oxide-2: #b35428;
15697 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15698 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15699 --success-bg: #e8f5ed;
15700 --success-text: #1a8f47;
15701 --info-bg: #eef3ff;
15702 --info-text: #4467d8;
15703 }
15704
15705 body.dark-theme {
15706 --bg: #1b1511;
15707 --surface: #261c17;
15708 --surface-2: #2d221d;
15709 --surface-3: #372922;
15710 --line: #524238;
15711 --line-strong: #6c5649;
15712 --text: #f5ece6;
15713 --muted: #c7b7aa;
15714 --muted-2: #aa9485;
15715 --nav: #b85d33;
15716 --nav-2: #7a371b;
15717 --accent: #6f9bff;
15718 --accent-2: #4a78ee;
15719 --oxide: #d37a4c;
15720 --oxide-2: #b35428;
15721 --shadow: 0 18px 42px rgba(0,0,0,0.28);
15722 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15723 --success-bg: #163927;
15724 --success-text: #8fe2a8;
15725 --info-bg: #1c2847;
15726 --info-text: #a9c1ff;
15727 }
15728
15729 * { box-sizing: border-box; }
15730 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); }
15731 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15732 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15733 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15734 .top-nav, .page { position: relative; z-index: 2; }
15735 .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); }
15736 .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; }
15737 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15738 .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)); }
15739 .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; }
15740 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15741 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15742 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15743 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15744 .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; }
15745 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15746 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15747 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15748 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15749 @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; } }
15750 .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; }
15751 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15752 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15753 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15754 .theme-toggle .icon-sun { display:none; }
15755 body.dark-theme .theme-toggle .icon-sun { display:block; }
15756 body.dark-theme .theme-toggle .icon-moon { display:none; }
15757 .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;}
15758 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15759 .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);}
15760 .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;}
15761 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15762 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15763 .settings-modal-body{padding:14px 16px 16px;}
15764 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15765 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15766 .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;}
15767 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15768 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15769 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15770 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15771 .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;}
15772 .tz-select:focus{border-color:var(--oxide);}
15773 .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; }
15774 .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;}
15775 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
15776 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15777 .hero, .panel { padding: 22px; }
15778 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15779 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15780 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15781 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15782 .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; }
15783 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15784 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15785 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15786 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15787 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15788 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15789 .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; }
15790 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15791 .delta-card-val { font-size:16px; font-weight:800; }
15792 .delta-card-val.pos { color:#1e7e34; }
15793 .delta-card-val.neg { color:var(--neg); }
15794 .delta-card-val.mod { color:#b35428; }
15795 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15796 .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; }
15797 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15798 .delta-card-inline:hover .delta-card-tip { opacity:1; }
15799 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15800 .compare-ts { font-size:13px; color:var(--muted); }
15801 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15802 .compare-arrow { color: var(--muted); }
15803 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15804 .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; }
15805 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15806 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15807 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15808 .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; }
15809 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15810 .run-mgmt-card .action-buttons { justify-content:center; }
15811 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
15812 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15813 .button, .copy-button {
15814 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;
15815 }
15816 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15817 @keyframes spin { to { transform: rotate(360deg); } }
15818 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15819 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15820 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15821 .path-item strong { display: block; margin-bottom: 6px; }
15822 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15823 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15824 .path-subitem { flex: 1; }
15825 .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); }
15826 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); }
15827 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15828 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15829 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15830 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15831 th { color: var(--muted); font-weight: 700; }
15832 tr:last-child td { border-bottom: none; }
15833 #subm-tbl col:nth-child(1){width:15%;}
15834 #subm-tbl col:nth-child(2){width:31%;}
15835 #subm-tbl col:nth-child(3){width:9%;}
15836 #subm-tbl col:nth-child(4){width:9%;}
15837 #subm-tbl col:nth-child(5){width:9%;}
15838 #subm-tbl col:nth-child(6){width:9%;}
15839 #subm-tbl col:nth-child(7){width:9%;}
15840 #subm-tbl col:nth-child(8){width:9%;}
15841 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15842 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15843 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15844 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15845 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15846 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15847 .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; }
15848 .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; }
15849 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
15850 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
15851 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15852 .muted { color: var(--muted); }
15853 /* Run-ID chip row (mirrors HTML report) */
15854 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
15855 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
15856 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
15857 .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; }
15858 .run-id-chip[data-copy] { cursor:pointer; }
15859 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15860 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15861 .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; }
15862 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15863 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15864 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
15865 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15866 a.commit-link-value { color:inherit; text-decoration:none; }
15867 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
15868 .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; }
15869 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15870 .run-id-chip:hover .chip-tooltip { opacity:1; }
15871 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15872 .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; }
15873 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15874 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15875 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15876 /* Meta chips row */
15877 .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%; }
15878 .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; }
15879 .meta-chip:last-child { border-right:none; }
15880 .meta-chip b { color:var(--text); font-weight:700; }
15881 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15882 .site-footer a{color:var(--muted);}
15883 .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; }
15884 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15885 .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; }
15886 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15887 /* Stat chips (matches HTML report) */
15888 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15889 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15890 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15891 .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; }
15892 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15893 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15894 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15895 .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; }
15896 .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; }
15897 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15898 .stat-chip:hover .stat-chip-tip { opacity:1; }
15899 /* Submodule panel */
15900 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15901 /* Metrics tables stack */
15902 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15903 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15904 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15905 .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)); }
15906 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15907 /* Metrics table */
15908 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15909 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15910 .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; }
15911 .metrics-table thead th:not(:first-child) { text-align: right; }
15912 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15913 .metrics-table tbody tr:last-child td { border-bottom: none; }
15914 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15915 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15916 .metrics-table tbody tr:hover td { background: var(--surface-2); }
15917 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15918 .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; }
15919 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15920 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15921 .mt-val-pos { color: var(--pos); font-weight: 700; }
15922 .mt-val-neg { color: var(--neg); font-weight: 700; }
15923 .mt-val-zero { color: var(--muted); }
15924 .mt-val-mod { color: var(--oxide-2); }
15925 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15926 @media (max-width: 1180px) {
15927 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15928 .nav-project-slot, .nav-status { justify-content:flex-start; }
15929 .hero-top { flex-direction: column; }
15930 .run-mgmt-strip { flex-direction: column; }
15931 }
15932 .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;}
15933 @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));}}
15934 .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;}
15935 /* ── Result-page chart controls ─────────────────────────────────────────── */
15936 .r-chart-section{margin-bottom:24px;}
15937 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15938 .section-pair > .panel{flex-shrink:0;}
15939 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15940 .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;}
15941 .r-chart-select:focus{border-color:var(--accent);}
15942 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15943 .r-chart-container svg{display:block;width:100%;height:auto;}
15944 .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;}
15945 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15946 .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;}
15947 .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);}
15948 .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;}
15949 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}
15950 .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;}
15951 .r-chart-modal-close:hover{opacity:.7;}
15952 body.dark-theme .r-chart-modal{background:var(--surface);}
15953 .r-chart-container .rchit,.r-expand-modal-chart .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15954 .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover{opacity:.75;filter:brightness(1.14);}
15955 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15956 .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;}
15957 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15958 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15959 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15960 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15961 #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;}
15962 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15963 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15964 .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;}
15965 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15966 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15967 .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;}
15968 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15969 .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%;}
15970 .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%;}
15971 body.has-report-banner .top-nav{top:27px;}
15972 body.has-report-banner{padding-bottom:27px;}
15973 </style>
15974</head>
15975<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15976 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" />
15991 </div>
15992 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15993 {% if let Some(banner) = report_header_footer %}
15994 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15995 {% endif %}
15996 <div class="top-nav">
15997 <div class="top-nav-inner">
15998 <a class="brand" href="/">
15999 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16000 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16001 </a>
16002 <div class="nav-project-slot">
16003 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
16004 </div>
16005 <div class="nav-status">
16006 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
16007 <div class="nav-dropdown">
16008 <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>
16009 <div class="nav-dropdown-menu">
16010 <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>
16011 </div>
16012 </div>
16013 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
16014 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16015 <div class="nav-dropdown">
16016 <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>
16017 <div class="nav-dropdown-menu">
16018 <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>
16019 </div>
16020 </div>
16021 <div class="server-status-wrap" id="server-status-wrap">
16022 <div class="nav-pill server-online-pill" id="server-status-pill">
16023 <span class="status-dot" id="status-dot"></span>
16024 <span id="server-status-label">Server</span>
16025 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16026 </div>
16027 <div class="server-status-tip">
16028 OxideSLOC is running — accessible on your network.
16029 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16030 </div>
16031 </div>
16032 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16033 <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>
16034 </button>
16035 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16036 <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>
16037 <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>
16038 </button>
16039 </div>
16040 </div>
16041 </div>
16042
16043 <div class="page">
16044 <section class="hero">
16045 <div class="hero-top">
16046 <div>
16047 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
16048 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
16049 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
16050 <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>
16051 </div>
16052 </div>
16053 <div class="hero-quick-actions">
16054 {% if server_mode %}
16055 <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>
16056 {% else %}
16057 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
16058 {% endif %}
16059 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
16060 {% if !server_mode %}
16061 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
16062 {% endif %}
16063 </div>
16064 </div>
16065
16066 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
16067 <div class="run-id-row">
16068 <span class="run-id-chip" data-copy="{{ run_id }}">
16069 <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>
16070 <span class="run-id-chip-value">{{ run_id }}</span>
16071 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
16072 </span>
16073 {% match git_commit_long %}
16074 {% when Some with (long_sha) %}
16075 {% match git_commit_url %}
16076 {% when Some with (commit_url) %}
16077 <span class="run-id-chip" data-copy="{{ long_sha }}">
16078 <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>
16079 <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
16080 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
16081 </span>
16082 {% when None %}
16083 <span class="run-id-chip" data-copy="{{ long_sha }}">
16084 <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>
16085 <span class="run-id-chip-value">{{ long_sha }}</span>
16086 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
16087 </span>
16088 {% endmatch %}
16089 {% when None %}
16090 <span class="run-id-chip muted-chip">
16091 <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>
16092 <span class="run-id-chip-value">Not detected</span>
16093 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
16094 </span>
16095 {% endmatch %}
16096 {% match git_branch %}
16097 {% when Some with (branch) %}
16098 <span class="run-id-chip">
16099 <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>
16100 <span class="run-id-chip-value">{{ branch }}</span>
16101 <span class="chip-tooltip">Git branch active at scan time</span>
16102 </span>
16103 {% when None %}
16104 <span class="run-id-chip muted-chip">
16105 <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>
16106 <span class="run-id-chip-value">Not detected</span>
16107 <span class="chip-tooltip">No Git branch was found for this scan</span>
16108 </span>
16109 {% endmatch %}
16110 {% match git_author %}
16111 {% when Some with (author) %}
16112 <span class="run-id-chip" data-author="{{ author }}">
16113 <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>
16114 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
16115 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
16116 </span>
16117 {% when None %}
16118 <span class="run-id-chip muted-chip">
16119 <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>
16120 <span class="run-id-chip-value">Not detected</span>
16121 <span class="chip-tooltip">No commit author was found for this scan</span>
16122 </span>
16123 {% endmatch %}
16124 </div>
16125
16126 <!-- Scan metadata row -->
16127 <div class="meta">
16128 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
16129 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
16130 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
16131 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
16132 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
16133 </div>
16134
16135 <!-- 12 summary stat chips -->
16136 <div class="summary-strip">
16137 <div class="stat-chip" data-raw="{{ physical_lines }}">
16138 <div class="stat-chip-label">Physical lines</div>
16139 <div class="stat-chip-val">{{ physical_lines }}</div>
16140 <div class="stat-chip-exact"></div>
16141 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
16142 </div>
16143 <div class="stat-chip" data-raw="{{ code_lines }}">
16144 <div class="stat-chip-label">Code</div>
16145 <div class="stat-chip-val">{{ code_lines }}</div>
16146 <div class="stat-chip-exact"></div>
16147 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
16148 </div>
16149 <div class="stat-chip" data-raw="{{ comment_lines }}">
16150 <div class="stat-chip-label">Comments</div>
16151 <div class="stat-chip-val">{{ comment_lines }}</div>
16152 <div class="stat-chip-exact"></div>
16153 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
16154 </div>
16155 <div class="stat-chip" data-raw="{{ blank_lines }}">
16156 <div class="stat-chip-label">Blank</div>
16157 <div class="stat-chip-val">{{ blank_lines }}</div>
16158 <div class="stat-chip-exact"></div>
16159 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
16160 </div>
16161 <div class="stat-chip" data-raw="{{ mixed_lines }}">
16162 <div class="stat-chip-label">Mixed separate</div>
16163 <div class="stat-chip-val">{{ mixed_lines }}</div>
16164 <div class="stat-chip-exact"></div>
16165 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
16166 </div>
16167 <div class="stat-chip" data-raw="{{ functions }}">
16168 <div class="stat-chip-label">Functions</div>
16169 <div class="stat-chip-val">{{ functions }}</div>
16170 <div class="stat-chip-exact"></div>
16171 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
16172 </div>
16173 <div class="stat-chip" data-raw="{{ classes }}">
16174 <div class="stat-chip-label">Classes / Types</div>
16175 <div class="stat-chip-val">{{ classes }}</div>
16176 <div class="stat-chip-exact"></div>
16177 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
16178 </div>
16179 <div class="stat-chip" data-raw="{{ variables }}">
16180 <div class="stat-chip-label">Variables</div>
16181 <div class="stat-chip-val">{{ variables }}</div>
16182 <div class="stat-chip-exact"></div>
16183 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
16184 </div>
16185 <div class="stat-chip" data-raw="{{ imports }}">
16186 <div class="stat-chip-label">Imports</div>
16187 <div class="stat-chip-val">{{ imports }}</div>
16188 <div class="stat-chip-exact"></div>
16189 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
16190 </div>
16191 <div class="stat-chip" data-raw="{{ test_count }}">
16192 <div class="stat-chip-label">Tests</div>
16193 <div class="stat-chip-val">{{ test_count }}</div>
16194 <div class="stat-chip-exact"></div>
16195 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
16196 </div>
16197 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
16198 <div class="stat-chip-label">Code density</div>
16199 <div class="stat-chip-val stat-chip-density-val">—</div>
16200 <div class="stat-chip-exact"></div>
16201 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
16202 </div>
16203 <div class="stat-chip" data-raw="{{ files_analyzed }}">
16204 <div class="stat-chip-label">Files analyzed</div>
16205 <div class="stat-chip-val">{{ files_analyzed }}</div>
16206 <div class="stat-chip-exact"></div>
16207 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
16208 </div>
16209 </div>
16210
16211 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
16212 <div class="compare-banner">
16213 <div class="compare-banner-body">
16214 <div class="compare-banner-meta">
16215 <span class="compare-label">Previous scan</span>
16216 <span class="compare-ts">{{ prev_ts }}</span>
16217 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
16218 {% if let Some(prev_code) = prev_run_code_lines %}
16219 <div class="compare-banner-stats" style="margin-top:4px;">
16220 <span>Code before: <strong>{{ prev_code }}</strong></span>
16221 <span class="compare-arrow">→</span>
16222 <span>Code now: <strong>{{ code_lines }}</strong></span>
16223 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
16224 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
16225 </div>
16226 {% endif %}
16227 </div>
16228 {% if delta_lines_added.is_some() %}
16229 <div class="delta-cards-inline">
16230 <div class="delta-card-inline">
16231 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
16232 <div class="delta-card-lbl">lines added</div>
16233 <div class="delta-card-tip">Code lines added since the previous scan</div>
16234 </div>
16235 <div class="delta-card-inline">
16236 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
16237 <div class="delta-card-lbl">lines removed</div>
16238 <div class="delta-card-tip">Code lines removed since the previous scan</div>
16239 </div>
16240 <div class="delta-card-inline">
16241 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
16242 <div class="delta-card-lbl">unmodified lines</div>
16243 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
16244 </div>
16245 <div class="delta-card-inline">
16246 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
16247 <div class="delta-card-lbl">files modified</div>
16248 <div class="delta-card-tip">Files with at least one line changed</div>
16249 </div>
16250 <div class="delta-card-inline">
16251 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
16252 <div class="delta-card-lbl">files added</div>
16253 <div class="delta-card-tip">New files added since the previous scan</div>
16254 </div>
16255 <div class="delta-card-inline">
16256 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
16257 <div class="delta-card-lbl">files removed</div>
16258 <div class="delta-card-tip">Files deleted since the previous scan</div>
16259 </div>
16260 <div class="delta-card-inline">
16261 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
16262 <div class="delta-card-lbl">files unchanged</div>
16263 <div class="delta-card-tip">Files with no changes since the previous scan</div>
16264 </div>
16265 </div>
16266 {% else %}
16267 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
16268 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
16269 </p>
16270 {% endif %}
16271 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
16272 </div>
16273 </div>
16274 {% endif %}{% endif %}
16275
16276 <div class="action-grid">
16277 <div class="action-card">
16278 <h3>HTML report</h3>
16279 <div class="action-buttons">
16280 {% match html_url %}
16281 {% when Some with (url) %}
16282 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
16283 {% when None %}{% endmatch %}
16284 {% match html_download_url %}
16285 {% when Some with (url) %}
16286 <a class="button secondary" href="{{ url }}">Download HTML</a>
16287 {% when None %}{% endmatch %}
16288 {% match html_path %}
16289 {% when Some with (_path) %}{% when None %}{% endmatch %}
16290 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
16291 </div>
16292 </div>
16293 <div class="action-card">
16294 <h3>PDF report</h3>
16295 <div class="action-buttons">
16296 {% match pdf_url %}
16297 {% when Some with (url) %}
16298 {% if pdf_generating %}
16299 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
16300 <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>
16301 Generating PDF…
16302 </button>
16303 {% else %}
16304 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
16305 {% endif %}
16306 {% when None %}
16307 {% match html_url %}
16308 {% when Some with (_hurl) %}
16309 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
16310 <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>
16311 {% when None %}
16312 <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;">
16313 PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
16314 </p>
16315 {% endmatch %}
16316 {% endmatch %}
16317 {% match pdf_download_url %}
16318 {% when Some with (url) %}
16319 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
16320 {% when None %}{% endmatch %}
16321 {% match pdf_url %}
16322 {% when Some with (_) %}
16323 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
16324 {% when None %}{% endmatch %}
16325 </div>
16326 </div>
16327 <div class="action-card">
16328 <h3>JSON result</h3>
16329 <div class="action-buttons">
16330 {% match json_url %}
16331 {% when Some with (url) %}
16332 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
16333 {% when None %}{% endmatch %}
16334 {% match json_download_url %}
16335 {% when Some with (url) %}
16336 <a class="button secondary" href="{{ url }}">Download JSON</a>
16337 {% when None %}{% endmatch %}
16338 {% match json_path %}
16339 {% when Some with (_path) %}
16340 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
16341 {% when None %}
16342 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
16343 {% endmatch %}
16344 </div>
16345 </div>
16346 <div class="action-card">
16347 <h3>Scan config</h3>
16348 <div class="action-buttons">
16349 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
16350 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
16351 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
16352 </div>
16353 </div>
16354 {% if confluence_configured %}
16355 <div class="action-card" id="confluenceCard">
16356 <h3>Confluence</h3>
16357 <div class="action-buttons">
16358 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
16359 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
16360 </div>
16361 <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>
16362 </div>
16363 {% endif %}
16364 </div>
16365 <div class="run-mgmt-strip">
16366 <div class="run-mgmt-card">
16367 <h3>Download bundle</h3>
16368 <div class="action-buttons">
16369 <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
16370 </div>
16371 <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>
16372 </div>
16373 <div class="run-mgmt-card" id="delete-run-card">
16374 <h3>Delete run</h3>
16375 <div class="action-buttons">
16376 <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16377 </div>
16378 <p class="action-empty-note" style="margin-top:6px;">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16379 </div>
16380 </div>
16381 {% if confluence_configured %}
16382 <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;">
16383 <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);">
16384 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16385 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16386 <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;">
16387 <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>
16388 <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;">
16389 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16390 <div style="display:flex;gap:10px;justify-content:flex-end;">
16391 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16392 <button class="button" id="confSubmitBtn" type="button">Post</button>
16393 </div>
16394 </div>
16395 </div>
16396 {% endif %}
16397 <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;">
16398 <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);">
16399 <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16400 <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>
16401 <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16402 <div style="display:flex;gap:10px;justify-content:flex-end;">
16403 <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16404 <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16405 </div>
16406 </div>
16407 </div>
16408 {% if !submodule_rows.is_empty() %}
16409 <div class="submodule-panel">
16410 <div class="toolbar-row">
16411 <div>
16412 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16413 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16414 </div>
16415 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16416 </div>
16417 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16418 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16419 <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>
16420 <thead>
16421 <tr>
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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Submodule</th>
16423 <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>
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;">Files</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;">Physical</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;">Code</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;">Comments</th>
16428 <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>
16429 <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>
16430 </tr>
16431 </thead>
16432 <tbody>
16433 {% for row in submodule_rows %}
16434 <tr>
16435 <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>
16436 <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>
16437 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16438 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16439 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16440 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16441 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16442 <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>
16443 </tr>
16444 {% endfor %}
16445 </tbody>
16446 </table>
16447 </div>
16448 </div>
16449 {% endif %}
16450
16451 <div class="metrics-tables-stack">
16452
16453 <div class="metrics-table-wrap">
16454 <div class="metrics-table-title">Files</div>
16455 <table class="metrics-table">
16456 <thead>
16457 <tr>
16458 <th>Metric</th>
16459 <th>This Run</th>
16460 <th>Previous</th>
16461 <th>Change</th>
16462 </tr>
16463 </thead>
16464 <tbody>
16465 <tr>
16466 <td>Files analyzed</td>
16467 <td class="mt-val-large">{{ files_analyzed }}</td>
16468 <td>{{ prev_fa_str }}</td>
16469 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16470 </tr>
16471 <tr>
16472 <td>Files skipped</td>
16473 <td>{{ files_skipped }}</td>
16474 <td>{{ prev_fs_str }}</td>
16475 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16476 </tr>
16477 <tr>
16478 <td>Files modified</td>
16479 <td class="mt-val-na">—</td>
16480 <td class="mt-val-na">—</td>
16481 <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>
16482 </tr>
16483 <tr>
16484 <td>Files unchanged</td>
16485 <td class="mt-val-na">—</td>
16486 <td class="mt-val-na">—</td>
16487 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16488 </tr>
16489 </tbody>
16490 </table>
16491 </div>
16492
16493 <div class="metrics-table-wrap">
16494 <div class="metrics-table-title">Line Counts</div>
16495 <table class="metrics-table">
16496 <thead>
16497 <tr>
16498 <th>Metric</th>
16499 <th>This Run</th>
16500 <th>Previous</th>
16501 <th>Change</th>
16502 </tr>
16503 </thead>
16504 <tbody>
16505 <tr>
16506 <td>Physical lines</td>
16507 <td class="mt-val-large">{{ physical_lines }}</td>
16508 <td>{{ prev_pl_str }}</td>
16509 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16510 </tr>
16511 <tr>
16512 <td>Code lines</td>
16513 <td class="mt-val-large">{{ code_lines }}</td>
16514 <td>{{ prev_cl_str }}</td>
16515 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16516 </tr>
16517 <tr>
16518 <td>Comment lines</td>
16519 <td>{{ comment_lines }}</td>
16520 <td>{{ prev_cml_str }}</td>
16521 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16522 </tr>
16523 <tr>
16524 <td>Blank lines</td>
16525 <td>{{ blank_lines }}</td>
16526 <td>{{ prev_bl_str }}</td>
16527 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16528 </tr>
16529 <tr>
16530 <td>Mixed (separate)</td>
16531 <td>{{ mixed_lines }}</td>
16532 <td class="mt-val-na">—</td>
16533 <td class="mt-val-na">—</td>
16534 </tr>
16535 </tbody>
16536 </table>
16537 </div>
16538
16539 <div class="metrics-tables-lower">
16540 <div class="metrics-table-wrap">
16541 <div class="metrics-table-title">Code Structure</div>
16542 <table class="metrics-table">
16543 <thead>
16544 <tr>
16545 <th>Metric</th>
16546 <th>This Run</th>
16547 </tr>
16548 </thead>
16549 <tbody>
16550 <tr>
16551 <td>Functions</td>
16552 <td>{{ functions }}</td>
16553 </tr>
16554 <tr>
16555 <td>Classes / Types</td>
16556 <td>{{ classes }}</td>
16557 </tr>
16558 <tr>
16559 <td>Variables</td>
16560 <td>{{ variables }}</td>
16561 </tr>
16562 <tr>
16563 <td>Imports</td>
16564 <td>{{ imports }}</td>
16565 </tr>
16566 </tbody>
16567 </table>
16568 </div>
16569
16570 <div class="metrics-table-wrap">
16571 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16572 <table class="metrics-table">
16573 <thead>
16574 <tr>
16575 <th>Metric</th>
16576 <th>Change</th>
16577 </tr>
16578 </thead>
16579 <tbody>
16580 <tr>
16581 <td>Lines added</td>
16582 <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>
16583 </tr>
16584 <tr>
16585 <td>Lines removed</td>
16586 <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>
16587 </tr>
16588 <tr>
16589 <td>Lines modified (net)</td>
16590 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16591 </tr>
16592 <tr>
16593 <td>Lines unmodified</td>
16594 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16595 </tr>
16596 </tbody>
16597 </table>
16598 </div>
16599 </div>
16600
16601 </div>
16602
16603 <div class="path-list">
16604 <div class="path-item">
16605 <div class="path-item-label">Project path</div>
16606 <code>{{ project_path }}</code>
16607 </div>
16608 <div class="path-item">
16609 <div class="path-item-label">Git branch</div>
16610 {% if let Some(branch) = git_branch %}
16611 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16612 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16613 {% else %}
16614 <code style="color:var(--muted)">—</code>
16615 {% endif %}
16616 </div>
16617 <div class="path-item">
16618 <div class="path-item-label">Output folder</div>
16619 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16620 </div>
16621 <div class="path-item">
16622 <div class="path-item-label">Run ID</div>
16623 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16624 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16625 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16626 </div>
16627 </div>
16628 </div>
16629 </section>
16630
16631 <div class="section-pair">
16632 <section class="panel">
16633 <div class="toolbar-row">
16634 <div>
16635 <h2>Language breakdown</h2>
16636 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16637 </div>
16638 </div>
16639 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16640 </section>
16641
16642 <section class="panel r-chart-section">
16643 <div class="toolbar-row" style="margin-bottom:16px;">
16644 <div>
16645 <h2>Visualizations</h2>
16646 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16647 </div>
16648 </div>
16649
16650 <div class="r-viz-grid">
16651 <div class="r-viz-card">
16652 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16653 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
16654 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16655 </div>
16656 <div class="r-chart-tab-bar">
16657 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16658 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16659 </div>
16660 <div class="r-chart-container" id="r-composition-chart"></div>
16661 </div>
16662 <div class="r-viz-card">
16663 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16664 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
16665 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16666 </div>
16667 <div class="r-chart-container" id="r-scatter-chart"></div>
16668 </div>
16669 {% if has_semantic_data %}
16670 <div class="r-viz-card">
16671 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16672 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16673 <select class="r-chart-select" id="r-semantic-metric">
16674 <option value="functions">Functions</option>
16675 <option value="classes">Classes</option>
16676 <option value="variables">Variables</option>
16677 <option value="imports">Imports</option>
16678 </select>
16679 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16680 </div>
16681 <div class="r-chart-container" id="r-semantic-chart"></div>
16682 </div>
16683 {% endif %}
16684 <div class="r-viz-card">
16685 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16686 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
16687 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16688 </div>
16689 <div class="r-chart-container" id="r-density-chart"></div>
16690 </div>
16691 <div class="r-viz-card">
16692 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16693 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
16694 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16695 </div>
16696 <div class="r-chart-container" id="r-avglines-chart"></div>
16697 </div>
16698 <div class="r-viz-card">
16699 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16700 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
16701 <select class="r-chart-select" id="r-sub-metric">
16702 <option value="code">Code Lines</option>
16703 <option value="comment">Comments</option>
16704 <option value="blank">Blank Lines</option>
16705 <option value="physical">Physical Lines</option>
16706 <option value="files">Files</option>
16707 </select>
16708 <select class="r-chart-select" id="r-sub-sort">
16709 <option value="desc">Value ↓</option>
16710 <option value="asc">Value ↑</option>
16711 <option value="name">Name A→Z</option>
16712 </select>
16713 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16714 </div>
16715 <div class="r-chart-container" id="r-submodule-chart"></div>
16716 </div>
16717 </div>
16718
16719 </section>
16720 </div>
16721
16722 </div>
16723
16724 <div id="r-tt" aria-hidden="true"></div>
16725
16726 <script nonce="{{ csp_nonce }}">
16727 (function () {
16728 var body = document.body;
16729 var themeToggle = document.getElementById('theme-toggle');
16730 var storageKey = 'oxide-sloc-theme';
16731
16732 function applyTheme(theme) {
16733 body.classList.toggle('dark-theme', theme === 'dark');
16734 }
16735
16736 function loadSavedTheme() {
16737 try {
16738 var saved = localStorage.getItem(storageKey);
16739 if (saved === 'dark' || saved === 'light') {
16740 applyTheme(saved);
16741 }
16742 } catch (e) {}
16743 }
16744
16745 if (themeToggle) {
16746 themeToggle.addEventListener('click', function () {
16747 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16748 applyTheme(nextTheme);
16749 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16750 });
16751 }
16752
16753 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16754 button.addEventListener('click', function () {
16755 var value = button.getAttribute('data-copy-value') || '';
16756 if (!value) return;
16757 var originalText = button.textContent;
16758 function flashSuccess() {
16759 button.textContent = 'Copied!';
16760 setTimeout(function () { button.textContent = originalText; }, 1800);
16761 }
16762 function flashFail() {
16763 button.textContent = 'Copy failed';
16764 setTimeout(function () { button.textContent = originalText; }, 2000);
16765 }
16766 if (navigator.clipboard && navigator.clipboard.writeText) {
16767 navigator.clipboard.writeText(value).then(flashSuccess, function () {
16768 fallbackCopy(value, flashSuccess, flashFail);
16769 });
16770 } else {
16771 fallbackCopy(value, flashSuccess, flashFail);
16772 }
16773 });
16774 });
16775 function fallbackCopy(text, onSuccess, onFail) {
16776 try {
16777 var ta = document.createElement('textarea');
16778 ta.value = text;
16779 ta.style.position = 'fixed';
16780 ta.style.top = '-9999px';
16781 ta.style.left = '-9999px';
16782 document.body.appendChild(ta);
16783 ta.focus();
16784 ta.select();
16785 var ok = document.execCommand('copy');
16786 document.body.removeChild(ta);
16787 if (ok) { onSuccess(); } else { onFail(); }
16788 } catch (e) { onFail(); }
16789 }
16790
16791 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16792 btn.addEventListener('click', function () {
16793 var folder = btn.getAttribute('data-folder') || '';
16794 if (!folder) return;
16795 fetch('/open-path?path=' + encodeURIComponent(folder))
16796 .then(function (r) { return r.json(); })
16797 .then(function (d) {
16798 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16799 })
16800 .catch(function () {});
16801 });
16802 });
16803
16804 loadSavedTheme();
16805
16806 // ── Compact number formatting for stat chips ──────────────────────────
16807 (function(){
16808 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();}
16809 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16810 var raw=parseInt(chip.getAttribute('data-raw'),10);
16811 if(isNaN(raw))return;
16812 var valEl=chip.querySelector('.stat-chip-val');
16813 if(valEl)valEl.textContent=fmt(raw);
16814 var exactEl=chip.querySelector('.stat-chip-exact');
16815 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16816 });
16817 // Code density chip
16818 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16819 var code=parseInt(chip.getAttribute('data-code'),10);
16820 var phys=parseInt(chip.getAttribute('data-physical'),10);
16821 if(isNaN(code)||isNaN(phys)||phys===0)return;
16822 var pct=(code/phys*100).toFixed(1)+'%';
16823 var valEl=chip.querySelector('.stat-chip-val');
16824 if(valEl)valEl.textContent=pct;
16825 });
16826 // Populate author handle from data-author attribute
16827 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
16828 var author=chip.getAttribute('data-author');
16829 var el=chip.querySelector('.author-handle');
16830 if(el)el.textContent='/'+author.replace(/\s+/g,'');
16831 });
16832 // Click-to-copy on run-id-chip elements
16833 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16834 chip.addEventListener('click',function(){
16835 var val=chip.getAttribute('data-copy');
16836 if(!val)return;
16837 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16838 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);}
16839 chip.classList.add('chip-copied-flash');
16840 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16841 });
16842 });
16843 })();
16844
16845 // ── Shared tooltip for all result-page charts ─────────────────────────
16846 var rTT=(function(){
16847 var el=document.getElementById('r-tt');
16848 if(!el)return{s:function(){},h:function(){},m:function(){}};
16849 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16850 function hide(){el.style.display='none';}
16851 function move(e){
16852 var x=e.clientX+16,y=e.clientY-12;
16853 var r=el.getBoundingClientRect();
16854 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16855 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16856 el.style.left=x+'px';el.style.top=y+'px';
16857 }
16858 return{s:show,h:hide,m:move};
16859 })();
16860 window.rTT=rTT;
16861
16862 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16863 (function(){
16864 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16865 document.addEventListener('mouseover',function(e){
16866 var t=e.target;
16867 while(t&&t.getAttribute){
16868 var l=t.getAttribute('data-ttl');
16869 if(l!==null){
16870 var v=t.getAttribute('data-ttv')||'';
16871 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16872 return;
16873 }
16874 t=t.parentNode;
16875 }
16876 });
16877 document.addEventListener('mouseout',function(e){
16878 var t=e.target;
16879 while(t&&t.getAttribute){
16880 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16881 t=t.parentNode;
16882 }
16883 });
16884 document.addEventListener('mousemove',function(e){
16885 var el=document.getElementById('r-tt');
16886 if(el&&el.style.display!=='none')rTT.m(e);
16887 });
16888 })();
16889
16890 // ── Language overview charts ───────────────────────────────────────────
16891 (function(){
16892 var D={{ lang_chart_json|safe }};
16893 if(!D||!D.length)return;
16894 var el=document.getElementById('result-lang-charts');
16895 if(!el)return;
16896 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16897 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16898 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16899 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();}
16900 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16901 function px(n){return Math.round(n);}
16902 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+'"';}
16903 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16904
16905 // Donut chart — height matches the stacked-bar chart so both panels align
16906 var rHb_d=28;
16907 var DH=Math.max(220,D.length*rHb_d+32);
16908 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16909 var legX=204,DW=360;
16910 var legCount=D.length;
16911 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16912 var legYStart=Math.round((DH-legCount*legSpacing)/2);
16913 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">';
16914 if(D.length===1){
16915 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16916 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+'"/>';
16917 } else {
16918 var ang=-Math.PI/2;
16919 D.forEach(function(d,i){
16920 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16921 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16922 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16923 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16924 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16925 var pct=Math.round(d.code/tot*100);
16926 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"/>';
16927 ang+=sw;
16928 });
16929 }
16930 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16931 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16932 D.forEach(function(d,i){
16933 var ly=legYStart+i*legSpacing;
16934 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16935 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16936 });
16937 ds+='</svg>';
16938
16939 // Horizontal stacked-bar chart — fills container width
16940 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16941 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16942 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">';
16943 D.forEach(function(d,i){
16944 var y=6+i*rHb,x=LW;
16945 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16946 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>';
16947 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;
16948 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;
16949 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"/>';
16950 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>';
16951 });
16952 var ly=SH-14;
16953 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>';
16954 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>';
16955 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>';
16956 bs+='</svg>';
16957 el.innerHTML='<div class="r-lang-overview">'+
16958 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16959 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16960 '</div>';
16961 })();
16962
16963 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16964 (function(){
16965 var LANG_D={{ lang_chart_json|safe }};
16966 var SCAT_D={{ scatter_chart_json|safe }};
16967 var SEM_D={{ semantic_chart_json|safe }};
16968 var SUB_D={{ submodule_chart_json|safe }};
16969 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16970 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16971 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();}
16972 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16973 function px(n){return Math.round(n);}
16974 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+'"';}
16975
16976 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16977 function renderCompositionInEl(el,mode,shOvr){
16978 if(!el||!LANG_D||!LANG_D.length)return;
16979 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16980 var LW=110,SH=shOvr||224;
16981 var svgW=Math.max(320,el.offsetWidth||480);
16982 var BW=Math.max(120,svgW-LW-80);
16983 var legendH=24,topPad=4;
16984 var n=LANG_D.length||1;
16985 var rowTotal=Math.floor((SH-legendH-topPad)/n);
16986 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16987 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">';
16988 if(mode==='pct'){
16989 LANG_D.forEach(function(d,i){
16990 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16991 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16992 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16993 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>';
16994 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;
16995 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;
16996 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+'"/>';
16997 var pct=Math.round((d.code||0)/tot2*100);
16998 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16999 });
17000 } else {
17001 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
17002 LANG_D.forEach(function(d,i){
17003 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
17004 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
17005 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>';
17006 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;
17007 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;
17008 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+'"/>';
17009 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>';
17010 });
17011 }
17012 var ly=SH-legendH+4;
17013 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>';
17014 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>';
17015 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>';
17016 s+='</svg>';
17017 el.innerHTML=s;
17018 }
17019 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
17020 renderComposition('abs');
17021 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
17022 btn.addEventListener('click',function(){
17023 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
17024 btn.classList.add('active');
17025 renderComposition(btn.getAttribute('data-rcomp'));
17026 });
17027 });
17028
17029 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
17030 function renderScatterInEl(el,hOvr){
17031 if(!el||!SCAT_D||!SCAT_D.length)return;
17032 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
17033 var W=Math.max(320,el.offsetWidth||480);
17034 var cW=W-PL-PR,cH=H-PT-PB;
17035 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
17036 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
17037 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
17038 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">';
17039 [0,0.25,0.5,0.75,1].forEach(function(t){
17040 var y=PT+cH*(1-t);
17041 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17042 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>';
17043 });
17044 [0,0.25,0.5,0.75,1].forEach(function(t){
17045 var x=PL+cW*t;
17046 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17047 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>';
17048 });
17049 SCAT_D.forEach(function(d,i){
17050 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
17051 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
17052 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"/>';
17053 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>';
17054 });
17055 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>';
17056 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>';
17057 s+='</svg>';
17058 el.innerHTML=s;
17059 }
17060 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17061
17062 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
17063 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
17064 // the old vertical column layout on wide containers.
17065 function renderSemanticInEl(el,key,sh){
17066 if(!el||!SEM_D||!SEM_D.length)return;
17067 var n2=SEM_D.length||1;
17068 var LW=112,SH=sh||Math.max(180,n2*28+26);
17069 var svgW=Math.max(320,el.offsetWidth||480);
17070 var BW=Math.max(120,svgW-LW-80);
17071 var topPad=4,botPad=14;
17072 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
17073 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
17074 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
17075 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">';
17076 SEM_D.forEach(function(d,i){
17077 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
17078 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>';
17079 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"/>';
17080 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>';
17081 });
17082 s+='</svg>';
17083 el.innerHTML=s;
17084 }
17085 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
17086 var semSel=document.getElementById('r-semantic-metric');
17087 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
17088 var semExpand=document.getElementById('r-semantic-expand');
17089 if(semExpand){
17090 semExpand.addEventListener('click',function(){
17091 var key=semSel?semSel.value:'functions';
17092 var semLabels={'functions':'Functions','classes':'Classes / Types','variables':'Variables'};
17093 var semSubtitle=semLabels[key]||key;
17094 var n=SEM_D.length||1;
17095 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
17096 var modalH=Math.min(Math.max(360,n*38+60),maxH);
17097 var overlay=document.createElement('div');
17098 overlay.className='r-chart-modal-overlay';
17099 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>';
17100 document.body.appendChild(overlay);
17101 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17102 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17103 var modalEl=document.getElementById('r-sem-modal-chart');
17104 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
17105 });
17106 }
17107
17108 // ── Expand buttons: re-render charts at large size inside modal ──────────
17109 (function(){
17110 function makeExpandModal(title,mH,subtitle){
17111 var overlay=document.createElement('div');
17112 overlay.className='r-chart-modal-overlay';
17113 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
17114 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>';
17115 document.body.appendChild(overlay);
17116 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17117 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17118 return overlay.querySelector('.r-expand-modal-chart');
17119 }
17120 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
17121 var compExpandBtn=document.getElementById('r-composition-expand');
17122 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
17123 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
17124 var modeLabel=modeKey==='pct'?'Composition %':'Absolute Lines';
17125 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17126 var wrap=makeExpandModal('Language Composition',mH,modeLabel);
17127 if(wrap)setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
17128 });}
17129 var scatExpandBtn=document.getElementById('r-scatter-expand');
17130 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
17131 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
17132 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
17133 });}
17134 var densExpandBtn=document.getElementById('r-density-expand');
17135 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
17136 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17137 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
17138 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
17139 });}
17140 var avgExpandBtn=document.getElementById('r-avglines-expand');
17141 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
17142 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
17143 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
17144 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
17145 });}
17146 var subExpandBtn=document.getElementById('r-submodule-expand');
17147 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
17148 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
17149 var metricLabels={'code':'Code Lines','comment':'Comments','blank':'Blank Lines','physical':'Physical Lines','files':'Files'};
17150 var sortLabels={'desc':'Value ↓','asc':'Value ↑','name':'Name A→Z'};
17151 var subLabel=(metricLabels[key]||key)+' · '+(sortLabels[sort]||sort);
17152 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
17153 var wrap=makeExpandModal('Repository Overview',mH,subLabel);
17154 if(wrap)setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
17155 });}
17156 })();
17157
17158 // ── Comment Density: comments / (code + comments) per language ───────────
17159 function renderDensityInEl(el,shOvr){
17160 if(!el||!LANG_D||!LANG_D.length)return;
17161 var n=LANG_D.length||1;
17162 var LW=112,SH=shOvr||Math.max(180,n*28+26);
17163 var svgW=Math.max(320,el.offsetWidth||480);
17164 var BW=Math.max(120,svgW-LW-80);
17165 var topPad=4,botPad=26;
17166 var rowTotal=Math.floor((SH-topPad-botPad)/n);
17167 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17168 var densities=LANG_D.map(function(d){
17169 var sig=(d.code||0)+(d.comments||0);
17170 return sig>0?(d.comments||0)/sig:0;
17171 });
17172 var maxDen=Math.max.apply(null,densities)||1;
17173 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">';
17174 LANG_D.forEach(function(d,i){
17175 var den=densities[i],bw=den/maxDen*BW;
17176 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17177 var pct=Math.round(den*100);
17178 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>';
17179 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"/>';
17180 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17181 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>';
17182 });
17183 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>';
17184 s+='</svg>';
17185 el.innerHTML=s;
17186 }
17187 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
17188 renderDensity();
17189
17190 // ── Avg Lines per File: code / files per language ─────────────────────
17191 function renderAvgLinesInEl(el,shOvr){
17192 if(!el||!LANG_D||!LANG_D.length)return;
17193 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
17194 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
17195 var n=data.length||1;
17196 var LW=112,SH=shOvr||Math.max(180,n*28+26);
17197 var svgW=Math.max(320,el.offsetWidth||480);
17198 var BW=Math.max(120,svgW-LW-80);
17199 var topPad=4,botPad=26;
17200 var rowTotal=Math.floor((SH-topPad-botPad)/n);
17201 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17202 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
17203 var maxAvg=Math.max.apply(null,avgs)||1;
17204 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">';
17205 data.forEach(function(d,i){
17206 var avg=avgs[i],bw=avg/maxAvg*BW;
17207 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17208 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>';
17209 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"/>';
17210 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17211 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>';
17212 });
17213 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>';
17214 s+='</svg>';
17215 el.innerHTML=s;
17216 }
17217 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
17218 renderAvgLines();
17219
17220 // ── Repository Overview: overall row + per-submodule rows ────────────
17221 function renderSubmoduleInEl(el,key,sort,shOvr){
17222 if(!el)return;
17223 var overall={
17224 name:'Overall',
17225 code:LANG_D.reduce(function(s,d){return s+(d.code||0);},0),
17226 comment:LANG_D.reduce(function(s,d){return s+(d.comments||0);},0),
17227 blank:LANG_D.reduce(function(s,d){return s+(d.blanks||0);},0),
17228 physical:SCAT_D.reduce(function(s,d){return s+(d.physical||0);},0),
17229 files:LANG_D.reduce(function(s,d){return s+(d.files||0);},0),
17230 isOverall:true
17231 };
17232 var subs=SUB_D.slice();
17233 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
17234 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
17235 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
17236 var data=[overall].concat(subs);
17237 var rowH=32,bH=22,sepH=subs.length>0?14:0;
17238 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
17239 var svgW=Math.max(320,el.offsetWidth||480);
17240 var LW=116,BW=Math.max(200,svgW-LW-54);
17241 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
17242 var OVERALL_COL='#6b7280';
17243 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">';
17244 var yOff=4;
17245 data.forEach(function(d,i){
17246 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
17247 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
17248 var label=d.name||d.path||'?';
17249 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>';
17250 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
17251 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17252 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>';
17253 yOff+=rowH;
17254 if(d.isOverall&&subs.length>0){
17255 yOff+=sepH;
17256 }
17257 });
17258 s+='</svg>';
17259 el.innerHTML=s;
17260 }
17261 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
17262 var subSel=document.getElementById('r-sub-metric');
17263 var sortSel=document.getElementById('r-sub-sort');
17264 renderSubmodule('code','desc');
17265 if(subSel){
17266 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
17267 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
17268 }
17269
17270 // Equalise heights within each chart row: if one chart in a grid row is taller
17271 // than its neighbour, re-render the shorter one at the taller height so bars fill
17272 // the available vertical space instead of leaving a gap.
17273 function syncRowHeights(){
17274 var avgEl=document.getElementById('r-avglines-chart');
17275 var subEl=document.getElementById('r-submodule-chart');
17276 if(avgEl&&subEl){
17277 var avgSvg=avgEl.querySelector('svg');
17278 var subSvg=subEl.querySelector('svg');
17279 if(avgSvg&&subSvg){
17280 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
17281 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
17282 var key=subSel?subSel.value||'code':'code';
17283 var sort=sortSel?sortSel.value:'desc';
17284 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
17285 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
17286 }
17287 }
17288 var semEl=document.getElementById('r-semantic-chart');
17289 var denEl=document.getElementById('r-density-chart');
17290 if(semEl&&denEl){
17291 var semSvg=semEl.querySelector('svg');
17292 var denSvg=denEl.querySelector('svg');
17293 if(semSvg&&denSvg){
17294 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
17295 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
17296 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
17297 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
17298 }
17299 }
17300 }
17301 syncRowHeights();
17302
17303 // Re-render all SVG charts when the window is resized so bars fill the card.
17304 var _rResizeTimer;
17305 window.addEventListener('resize',function(){
17306 clearTimeout(_rResizeTimer);
17307 _rResizeTimer=setTimeout(function(){
17308 var rcompBtn=document.querySelector('[data-rcomp].active');
17309 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
17310 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17311 if(semSel)renderSemantic(semSel.value||'functions');
17312 renderDensity();
17313 renderAvgLines();
17314 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
17315 syncRowHeights();
17316 },120);
17317 });
17318 })();
17319
17320 (function randomizeWatermarks() {
17321 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
17322 if (!wms.length) return;
17323 var placed = [];
17324 function tooClose(top, left) {
17325 for (var i = 0; i < placed.length; i++) {
17326 var dt = Math.abs(placed[i][0] - top);
17327 var dl = Math.abs(placed[i][1] - left);
17328 if (dt < 20 && dl < 18) return true;
17329 }
17330 return false;
17331 }
17332 function pick(leftBand) {
17333 for (var attempt = 0; attempt < 50; attempt++) {
17334 var top = Math.random() * 85 + 5;
17335 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17336 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
17337 }
17338 var top = Math.random() * 85 + 5;
17339 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17340 placed.push([top, left]);
17341 return [top, left];
17342 }
17343 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
17344 var half = Math.floor(wms.length / 2);
17345 wms.forEach(function (img, i) {
17346 var pos = pick(i < half);
17347 var size = Math.floor(Math.random() * 100 + 160);
17348 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
17349 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
17350 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;
17351 });
17352 })();
17353
17354 (function spawnCodeParticles() {
17355 var container = document.getElementById('code-particles');
17356 if (!container) return;
17357 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'];
17358 for (var i = 0; i < 38; i++) {
17359 (function(idx) {
17360 var el = document.createElement('span');
17361 el.className = 'code-particle';
17362 el.textContent = snippets[idx % snippets.length];
17363 var left = Math.random() * 94 + 2;
17364 var top = Math.random() * 88 + 6;
17365 var dur = (Math.random() * 10 + 9).toFixed(1);
17366 var delay = (Math.random() * 18).toFixed(1);
17367 var rot = (Math.random() * 26 - 13).toFixed(1);
17368 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17369 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';
17370 container.appendChild(el);
17371 })(i);
17372 }
17373 })();
17374
17375 {% if pdf_generating %}
17376 // Poll for PDF readiness and swap the disabled button to a live link once done.
17377 (function() {
17378 var openBtn = document.getElementById('pdf-open-btn');
17379 var dlBtn = document.getElementById('pdf-download-btn');
17380 function checkPdf() {
17381 fetch('/api/runs/{{ run_id }}/pdf-status')
17382 .then(function(r) { return r.json(); })
17383 .then(function(d) {
17384 if (d.ready) {
17385 if (openBtn) {
17386 var a = document.createElement('a');
17387 a.className = 'button';
17388 a.id = 'pdf-open-btn';
17389 a.href = '/runs/pdf/{{ run_id }}';
17390 a.target = '_blank';
17391 a.rel = 'noopener';
17392 a.textContent = 'Open PDF';
17393 openBtn.replaceWith(a);
17394 }
17395 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
17396 } else {
17397 setTimeout(checkPdf, 3000);
17398 }
17399 })
17400 .catch(function() { setTimeout(checkPdf, 5000); });
17401 }
17402 setTimeout(checkPdf, 3000);
17403 })();
17404 {% endif %}
17405
17406 })();
17407 </script>
17408 <script nonce="{{ csp_nonce }}">
17409 (function(){
17410 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'}];
17411 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);});}
17412 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17413 function init(){
17414 var btn=document.getElementById('settings-btn');if(!btn)return;
17415 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17416 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>';
17417 document.body.appendChild(m);
17418 var g=document.getElementById('scheme-grid');
17419 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);});
17420 var cl=document.getElementById('settings-close');
17421 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);
17422 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');});
17423 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17424 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17425 }
17426 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17427 }());
17428 </script>
17429 <footer class="site-footer">
17430 local code analysis - metrics, history and reports
17431 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17432 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17433 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17434 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17435 · <a href="/api-docs" rel="noopener">REST API</a>
17436 </footer>
17437 {% if confluence_configured %}
17438 <script nonce="{{ csp_nonce }}">
17439 (function() {
17440 var postBtn = document.getElementById('postConfluenceBtn');
17441 var copyBtn = document.getElementById('copyWikiBtn');
17442 var modal = document.getElementById('confluenceModal');
17443 if (!postBtn || !modal) return;
17444
17445 postBtn.addEventListener('click', function() {
17446 document.getElementById('confStatus').style.display = 'none';
17447 modal.style.display = 'flex';
17448 });
17449 document.getElementById('confCancelBtn').addEventListener('click', function() {
17450 modal.style.display = 'none';
17451 });
17452 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17453
17454 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
17455 var btn = this;
17456 btn.disabled = true;
17457 var status = document.getElementById('confStatus');
17458 status.style.display = 'block';
17459 status.style.background = '#dbeafe';
17460 status.style.color = '#1e40af';
17461 status.textContent = 'Posting to Confluence…';
17462 var resp = await fetch('/api/confluence/post', {
17463 method: 'POST',
17464 headers: { 'Content-Type': 'application/json' },
17465 body: JSON.stringify({
17466 run_id: '{{ run_id }}',
17467 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
17468 report_url: document.getElementById('confReportUrl').value.trim() || null
17469 })
17470 });
17471 var data = await resp.json();
17472 if (data.ok) {
17473 status.style.background = '#dcfce7'; status.style.color = '#166534';
17474 status.textContent = 'Posted! Page ID: ' + data.page_id;
17475 } else {
17476 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17477 status.textContent = 'Error: ' + (data.error || 'Unknown error');
17478 }
17479 btn.disabled = false;
17480 });
17481
17482 if (copyBtn) {
17483 copyBtn.addEventListener('click', async function() {
17484 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
17485 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
17486 var text = await resp.text();
17487 try {
17488 await navigator.clipboard.writeText(text);
17489 var orig = copyBtn.textContent;
17490 copyBtn.textContent = 'Copied!';
17491 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
17492 } catch(e) {
17493 alert('Clipboard write failed — check browser permissions.');
17494 }
17495 });
17496 }
17497 })();
17498 </script>
17499 {% endif %}
17500 <script nonce="{{ csp_nonce }}">
17501 (function() {
17502 var deleteBtn = document.getElementById('delete-run-btn');
17503 var modal = document.getElementById('delete-run-modal');
17504 var cancelBtn = document.getElementById('delete-run-cancel');
17505 var confirmBtn= document.getElementById('delete-run-confirm');
17506 if (!deleteBtn || !modal) return;
17507 deleteBtn.addEventListener('click', function() {
17508 document.getElementById('delete-run-status').style.display = 'none';
17509 modal.style.display = 'flex';
17510 });
17511 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
17512 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17513 confirmBtn.addEventListener('click', async function() {
17514 confirmBtn.disabled = true;
17515 cancelBtn.disabled = true;
17516 var status = document.getElementById('delete-run-status');
17517 status.style.display = 'block';
17518 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17519 status.textContent = 'Deleting…';
17520 try {
17521 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17522 if (resp.status === 204 || resp.ok) {
17523 status.style.background = '#dcfce7'; status.style.color = '#166534';
17524 status.textContent = 'Deleted. Redirecting…';
17525 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17526 } else {
17527 var d = await resp.json().catch(function(){return {};});
17528 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17529 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17530 confirmBtn.disabled = false;
17531 cancelBtn.disabled = false;
17532 }
17533 } catch (e) {
17534 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17535 status.textContent = 'Network error: ' + String(e);
17536 confirmBtn.disabled = false;
17537 cancelBtn.disabled = false;
17538 }
17539 });
17540 })();
17541 </script>
17542 <script nonce="{{ csp_nonce }}">(function(){
17543 var bundleBtn = document.getElementById('download-bundle-btn');
17544 if (bundleBtn) {
17545 bundleBtn.addEventListener('click', function() {
17546 bundleBtn.disabled = true;
17547 var orig = bundleBtn.textContent;
17548 bundleBtn.textContent = 'Preparing…';
17549 fetch('/api/runs/{{ run_id }}/bundle')
17550 .then(function(r) {
17551 if (!r.ok) throw new Error('HTTP ' + r.status);
17552 return r.blob();
17553 })
17554 .then(function(blob) {
17555 var url = URL.createObjectURL(blob);
17556 var a = document.createElement('a');
17557 a.href = url;
17558 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17559 document.body.appendChild(a);
17560 a.click();
17561 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17562 bundleBtn.disabled = false;
17563 bundleBtn.textContent = orig;
17564 })
17565 .catch(function(e) {
17566 bundleBtn.disabled = false;
17567 bundleBtn.textContent = orig;
17568 alert('Bundle download failed: ' + String(e));
17569 });
17570 });
17571 }
17572 })();</script>
17573 <script nonce="{{ csp_nonce }}">(function(){
17574 var dot=document.getElementById('status-dot');
17575 var pingEl=document.getElementById('server-ping-ms');
17576 var tipEl=document.getElementById('server-tip-ping');
17577 var fm=document.getElementById('footer-mode');
17578 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)';}}
17579 function doPing(){
17580 var t0=performance.now();
17581 fetch('/healthz',{cache:'no-store'})
17582 .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);})
17583 .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)';}});
17584 }
17585 doPing();
17586 setInterval(doPing,5000);
17587 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');}
17588 })();</script>
17589 {% if let Some(banner) = report_header_footer %}
17590 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17591 {% endif %}
17592</body>
17593</html>
17594"##,
17595 ext = "html"
17596)]
17597#[allow(clippy::struct_excessive_bools)]
17599struct ResultTemplate {
17600 version: &'static str,
17601 report_title: String,
17602 project_path: String,
17603 output_dir: String,
17604 run_id: String,
17605 files_analyzed: u64,
17606 files_skipped: u64,
17607 physical_lines: u64,
17608 code_lines: u64,
17609 comment_lines: u64,
17610 blank_lines: u64,
17611 mixed_lines: u64,
17612 functions: u64,
17613 classes: u64,
17614 variables: u64,
17615 imports: u64,
17616 html_url: Option<String>,
17617 pdf_url: Option<String>,
17618 json_url: Option<String>,
17619 html_download_url: Option<String>,
17620 pdf_download_url: Option<String>,
17621 json_download_url: Option<String>,
17622 html_path: Option<String>,
17623 json_path: Option<String>,
17624 prev_run_id: Option<String>,
17625 prev_run_timestamp: Option<String>,
17626 prev_run_code_lines: Option<u64>,
17627 prev_fa_str: String,
17629 prev_fs_str: String,
17630 prev_pl_str: String,
17631 prev_cl_str: String,
17632 prev_cml_str: String,
17633 prev_bl_str: String,
17634 delta_fa_str: String,
17636 delta_fa_class: String,
17637 delta_fs_str: String,
17638 delta_fs_class: String,
17639 delta_pl_str: String,
17640 delta_pl_class: String,
17641 delta_cl_str: String,
17642 delta_cl_class: String,
17643 delta_cml_str: String,
17644 delta_cml_class: String,
17645 delta_bl_str: String,
17646 delta_bl_class: String,
17647 delta_lines_added: Option<i64>,
17649 delta_lines_removed: Option<i64>,
17650 delta_lines_net_str: String,
17651 delta_lines_net_class: String,
17652 delta_files_added: Option<usize>,
17653 delta_files_removed: Option<usize>,
17654 delta_files_modified: Option<usize>,
17655 delta_files_unchanged: Option<usize>,
17656 delta_unmodified_lines: Option<u64>,
17657 git_branch: Option<String>,
17659 git_commit: Option<String>,
17660 git_commit_long: Option<String>,
17661 git_author: Option<String>,
17662 git_commit_url: Option<String>,
17663 scan_performed_by: String,
17665 scan_time_display: String,
17666 os_display: String,
17667 test_count: u64,
17668 prev_scan_count: usize,
17670 current_scan_number: usize,
17671 submodule_rows: Vec<SubmoduleRow>,
17673 scan_config_url: String,
17674 lang_chart_json: String,
17675 #[allow(dead_code)]
17677 scatter_chart_json: String,
17678 #[allow(dead_code)]
17679 semantic_chart_json: String,
17680 #[allow(dead_code)]
17681 submodule_chart_json: String,
17682 #[allow(dead_code)]
17683 has_submodule_data: bool,
17684 #[allow(dead_code)]
17685 has_semantic_data: bool,
17686 pdf_generating: bool,
17687 csp_nonce: String,
17688 confluence_configured: bool,
17690 server_mode: bool,
17691 report_header_footer: Option<String>,
17693 run_id_short: String,
17694}
17695
17696#[derive(Template)]
17697#[template(
17698 source = r##"
17699<!doctype html>
17700<html lang="en">
17701<head>
17702 <meta charset="utf-8">
17703 <meta name="viewport" content="width=device-width, initial-scale=1">
17704 <title>OxideSLOC | Analyzing…</title>
17705 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17706 <style nonce="{{ csp_nonce }}">
17707 :root {
17708 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17709 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17710 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17711 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17712 }
17713 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17714 *{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;}
17715 .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);}
17716 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17717 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17718 .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));}
17719 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17720 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17721 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17722 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17723 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17724 @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; } }
17725 .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;}
17726 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17727 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17728 .page-body{padding:32px 24px 36px;}
17729 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17730 .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;}
17731 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17732 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17733 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17734 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17735 .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;}
17736 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17737 .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;}
17738 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17739 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17740 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17741 .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;}
17742 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17743 .hidden{display:none!important;}
17744 .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;}
17745 .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;}
17746 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17747 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17748 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17749 .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);}
17750 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17751 .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;}
17752 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17753 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17754 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17755 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17756 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17757 .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;}
17758 @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));}}
17759 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17760 .site-footer a{color:var(--muted);}
17761 .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;}
17762 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17763 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17764 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17765 </style>
17766</head>
17767<body>
17768 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17775 </div>
17776 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17777 <nav class="top-nav">
17778 <div class="top-nav-inner">
17779 <a href="/" class="brand">
17780 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17781 <div class="brand-copy">
17782 <h1 class="brand-title">OxideSLOC</h1>
17783 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17784 </div>
17785 </a>
17786 <div class="nav-right">
17787 <a class="nav-pill" href="/">Home</a>
17788 <div class="nav-dropdown">
17789 <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>
17790 <div class="nav-dropdown-menu">
17791 <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>
17792 </div>
17793 </div>
17794 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17795 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17796 <div class="nav-dropdown">
17797 <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>
17798 <div class="nav-dropdown-menu">
17799 <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>
17800 </div>
17801 </div>
17802 <div class="server-status-wrap" id="server-status-wrap">
17803 <div class="nav-pill server-online-pill" id="server-status-pill">
17804 <span class="status-dot" id="status-dot"></span>
17805 <span id="server-status-label">Server</span>
17806 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17807 </div>
17808 <div class="server-status-tip">
17809 OxideSLOC is running — accessible on your network.
17810 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17811 </div>
17812 </div>
17813 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17814 <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>
17815 </button>
17816 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17817 <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>
17818 <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>
17819 </button>
17820 </div>
17821 </div>
17822 </nav>
17823 <div class="page-body">
17824 <div class="wait-panel">
17825 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17826 <h2 class="wait-title">Analyzing your project…</h2>
17827 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17828 <div class="path-block">{{ project_path }}</div>
17829 <div class="metrics-row">
17830 <div class="metric-card">
17831 <div class="metric-label">Elapsed</div>
17832 <div class="metric-value" id="elapsed">0s</div>
17833 </div>
17834 <div class="metric-card">
17835 <div class="metric-label">Phase</div>
17836 <div class="metric-value" id="phase">Starting</div>
17837 </div>
17838 <div class="metric-card hidden" id="files-card">
17839 <div class="metric-label">Files</div>
17840 <div class="metric-value" id="files-progress">0</div>
17841 </div>
17842 </div>
17843 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17844 <div class="warn-slow hidden" id="warn-slow">
17845 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.
17846 </div>
17847 <div class="err-panel hidden" id="err-panel">
17848 <strong>Analysis failed</strong>
17849 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17850 </div>
17851 <div class="actions hidden" id="actions">
17852 <a href="/scan" class="btn-primary">Try Again</a>
17853 <a href="/view-reports" class="btn-outline">View Reports</a>
17854 </div>
17855 </div>
17856 </div>
17857 <script nonce="{{ csp_nonce }}">
17858 (function() {
17859 var WAIT_ID = {{ wait_id_json|safe }};
17860 var startTime = Date.now();
17861 var pollInterval = 1500;
17862 var retries = 0;
17863 var maxRetries = 5;
17864 var warnShown = false;
17865
17866 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();}
17867
17868 function elapsed() {
17869 return Math.floor((Date.now() - startTime) / 1000);
17870 }
17871
17872 function updateElapsed() {
17873 var s = elapsed();
17874 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17875 }
17876
17877 function setPhase(txt) {
17878 document.getElementById('phase').textContent = txt;
17879 }
17880
17881 var elapsedTimer = setInterval(updateElapsed, 1000);
17882
17883 function poll() {
17884 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17885 .then(function(r) {
17886 if (!r.ok) throw new Error('HTTP ' + r.status);
17887 return r.json();
17888 })
17889 .then(function(data) {
17890 retries = 0;
17891 if (data.state === 'complete') {
17892 clearInterval(elapsedTimer);
17893 setPhase('Done');
17894 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17895 } else if (data.state === 'failed') {
17896 clearInterval(elapsedTimer);
17897 setPhase('Failed');
17898 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17899 document.getElementById('err-panel').classList.remove('hidden');
17900 document.getElementById('actions').classList.remove('hidden');
17901 } else {
17902 // still running
17903 var s = elapsed();
17904 if (s > 90 && !warnShown) {
17905 warnShown = true;
17906 document.getElementById('warn-slow').classList.remove('hidden');
17907 }
17908 setPhase(data.phase || 'Running');
17909 var fd = data.files_done || 0, ft = data.files_total || 0;
17910 if (ft > 0) {
17911 var card = document.getElementById('files-card');
17912 if (card) card.classList.remove('hidden');
17913 var fp = document.getElementById('files-progress');
17914 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
17915 }
17916 setTimeout(poll, pollInterval);
17917 }
17918 })
17919 .catch(function(err) {
17920 retries++;
17921 if (retries >= maxRetries) {
17922 clearInterval(elapsedTimer);
17923 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17924 document.getElementById('err-panel').classList.remove('hidden');
17925 document.getElementById('actions').classList.remove('hidden');
17926 } else {
17927 // exponential back-off capped at 8s
17928 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17929 }
17930 });
17931 }
17932
17933 setTimeout(poll, pollInterval);
17934 })();
17935 </script>
17936 <footer class="site-footer">
17937 local code analysis - metrics, history and reports
17938 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17939 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17940 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17941 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17942 · <a href="/api-docs" rel="noopener">REST API</a>
17943 </footer>
17944 <script nonce="{{ csp_nonce }}">
17945 (function(){
17946 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17947 if(s==="dark")b.classList.add("dark-theme");
17948 var tt=document.getElementById("theme-toggle");
17949 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17950 })();
17951 (function spawnCodeParticles(){
17952 var c=document.getElementById('code-particles');if(!c)return;
17953 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'];
17954 for(var i=0;i<32;i++){(function(idx){
17955 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17956 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17957 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17958 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17959 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17960 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17961 c.appendChild(el);
17962 })(i);}
17963 })();
17964 (function randomizeWatermarks(){
17965 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17966 var placed=[];
17967 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;}
17968 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];}
17969 var half=Math.floor(wms.length/2);
17970 wms.forEach(function(img,i){
17971 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17972 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17973 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17974 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17975 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17976 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17977 });
17978 })();
17979 </script>
17980 <script nonce="{{ csp_nonce }}">
17981 (function(){
17982 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'}];
17983 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);});}
17984 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17985 function init(){
17986 var btn=document.getElementById('settings-btn');if(!btn)return;
17987 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17988 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>';
17989 document.body.appendChild(m);
17990 var g=document.getElementById('scheme-grid');
17991 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);});
17992 var cl=document.getElementById('settings-close');
17993 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);
17994 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');});
17995 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17996 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17997 }
17998 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17999 }());
18000 </script>
18001 <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>
18002</body>
18003</html>
18004"##,
18005 ext = "html"
18006)]
18007struct ScanWaitTemplate {
18008 version: &'static str,
18009 wait_id_json: String,
18010 project_path: String,
18011 csp_nonce: String,
18012}
18013
18014#[derive(Template)]
18015#[template(
18016 source = r##"
18017<!doctype html>
18018<html lang="en">
18019<head>
18020 <meta charset="utf-8">
18021 <meta name="viewport" content="width=device-width, initial-scale=1">
18022 <title>OxideSLOC | Error</title>
18023 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18024 <style nonce="{{ csp_nonce }}">
18025 :root {
18026 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18027 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18028 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18029 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18030 }
18031 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18032 *{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;}
18033 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18034 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18035 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18036 .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);}
18037 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18038 .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));}
18039 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18040 .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;}
18041 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18042 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18043 @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; } }
18044 .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;}
18045 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18046 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18047 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18048 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18049 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18050 .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;}
18051 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18052 .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);}
18053 .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;}
18054 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18055 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18056 .settings-modal-body{padding:14px 16px 16px;}
18057 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18058 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18059 .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;}
18060 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18061 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18062 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18063 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18064 .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;}
18065 .tz-select:focus{border-color:var(--oxide);}
18066 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18067 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18068 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18069 .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;}
18070 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18071 .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);}
18072 .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;}
18073 .btn-secondary:hover{background:var(--line);}
18074 .bug-report-wrap{margin-top:22px;border-top:1px solid var(--line);padding-top:16px;}
18075 .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;}
18076 .bug-report-wrap summary::-webkit-details-marker{display:none;}
18077 .bug-report-arrow{display:inline-block;font-size:9px;transition:transform .15s ease;}
18078 .bug-report-wrap[open] .bug-report-arrow{transform:rotate(90deg);}
18079 .bug-report-wrap summary:hover{color:var(--text);}
18080 .bug-report-body{margin-top:12px;display:flex;flex-direction:column;gap:10px;}
18081 .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;}
18082 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
18083 .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;}
18084 .btn-sm:hover{background:var(--line);}
18085 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
18086 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
18087 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
18088 .bug-report-hint a:hover{text-decoration:underline;}
18089 .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;}
18090 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
18091 .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;}
18092 .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;}
18093 .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;}
18094 @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));}}
18095 .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;}
18096 </style>
18097</head>
18098<body>
18099 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18106 </div>
18107 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18108 <div class="top-nav">
18109 <div class="top-nav-inner">
18110 <a class="brand" href="/">
18111 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18112 <div class="brand-copy">
18113 <div class="brand-title">OxideSLOC</div>
18114 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18115 </div>
18116 </a>
18117 <div class="nav-right">
18118 <a class="nav-pill" href="/">Home</a>
18119 <div class="nav-dropdown">
18120 <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>
18121 <div class="nav-dropdown-menu">
18122 <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>
18123 </div>
18124 </div>
18125 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18126 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18127 <div class="nav-dropdown">
18128 <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>
18129 <div class="nav-dropdown-menu">
18130 <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>
18131 </div>
18132 </div>
18133 <div class="server-status-wrap" id="server-status-wrap">
18134 <div class="nav-pill server-online-pill" id="server-status-pill">
18135 <span class="status-dot" id="status-dot"></span>
18136 <span id="server-status-label">Server</span>
18137 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18138 </div>
18139 <div class="server-status-tip">
18140 OxideSLOC is running — accessible on your network.
18141 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18142 </div>
18143 </div>
18144 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18145 <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>
18146 </button>
18147 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18148 <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>
18149 <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>
18150 </button>
18151 </div>
18152 </div>
18153 </div>
18154
18155 <div class="page">
18156 <div class="panel">
18157 <h1>Error</h1>
18158 <div class="error-box" id="error-msg-text">{{ message }}</div>
18159 <div id="br-meta" hidden
18160 data-version="{{ version }}"
18161 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
18162 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
18163 <div class="actions">
18164 <a class="btn-primary" href="/scan">Back to setup</a>
18165 {% if let Some(report_url) = last_report_url %}
18166 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
18167 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
18168 {% else %}
18169 <a class="btn-secondary" href="/view-reports">View Reports</a>
18170 {% endif %}
18171 </div>
18172 <details class="bug-report-wrap" id="bug-report-wrap">
18173 <summary><span class="bug-report-arrow">►</span> Generate bug report</summary>
18174 <div class="bug-report-body">
18175 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
18176 <div class="bug-report-btns">
18177 <button type="button" class="btn-sm" id="bug-report-copy">
18178 <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>
18179 Copy to clipboard
18180 </button>
18181 <a class="btn-sm" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">
18182 <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>
18183 Open GitHub Issue
18184 </a>
18185 </div>
18186 <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>
18187 </div>
18188 </details>
18189 </div>
18190 </div>
18191 <footer class="site-footer">
18192 oxide-sloc v{{ version }} — local code metrics workbench ·
18193 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18194 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18195 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18196 · <a href="/api-docs" rel="noopener">REST API</a>
18197 </footer>
18198 <script nonce="{{ csp_nonce }}">(function(){
18199 var meta=document.getElementById('br-meta');
18200 var pre=document.getElementById('bug-report-pre');
18201 var copyBtn=document.getElementById('bug-report-copy');
18202 if(!meta||!pre)return;
18203 var ver=meta.getAttribute('data-version')||'';
18204 var runId=meta.getAttribute('data-run-id')||'';
18205 var code=meta.getAttribute('data-error-code')||'';
18206 var msgEl=document.getElementById('error-msg-text');
18207 var msg=msgEl?msgEl.textContent.trim():'';
18208 function getBrowser(){
18209 var ua=navigator.userAgent;
18210 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
18211 if(!m)return 'Unknown browser';
18212 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
18213 return n+' '+m[2];
18214 }
18215 var lines=['oxide-sloc Bug Report','==============================',''];
18216 lines.push('App version: v'+ver);
18217 if(code)lines.push('HTTP status: '+code);
18218 if(runId)lines.push('Run ID: '+runId);
18219 lines.push('Page: '+window.location.pathname+(window.location.search||''));
18220 lines.push('Timestamp: '+new Date().toISOString());
18221 lines.push('Browser: '+getBrowser());
18222 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
18223 lines.push('');
18224 lines.push('Error message:');
18225 lines.push(msg);
18226 lines.push('');
18227 lines.push('Steps to reproduce:');
18228 lines.push(' 1. ');
18229 lines.push('');
18230 lines.push('Expected behavior:');
18231 lines.push(' ');
18232 pre.textContent=lines.join('\n');
18233 if(copyBtn){
18234 copyBtn.addEventListener('click',function(){
18235 var txt=pre.textContent;
18236 if(navigator.clipboard&&navigator.clipboard.writeText){
18237 navigator.clipboard.writeText(txt).then(function(){
18238 copyBtn.textContent='[OK] Copied!';
18239 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);
18240 });
18241 }else{
18242 var ta=document.createElement('textarea');
18243 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
18244 document.body.appendChild(ta);ta.select();
18245 try{document.execCommand('copy');copyBtn.textContent='[OK] Copied!';}catch(e){}
18246 document.body.removeChild(ta);
18247 }
18248 });
18249 }
18250 })();</script>
18251 <script nonce="{{ csp_nonce }}">
18252 (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");});})();
18253 (function spawnCodeParticles() {
18254 var container = document.getElementById('code-particles');
18255 if (!container) return;
18256 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'];
18257 for (var i = 0; i < 38; i++) {
18258 (function(idx) {
18259 var el = document.createElement('span');
18260 el.className = 'code-particle';
18261 el.textContent = snippets[idx % snippets.length];
18262 var left = Math.random() * 94 + 2;
18263 var top = Math.random() * 88 + 6;
18264 var dur = (Math.random() * 10 + 9).toFixed(1);
18265 var delay = (Math.random() * 18).toFixed(1);
18266 var rot = (Math.random() * 26 - 13).toFixed(1);
18267 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18268 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';
18269 container.appendChild(el);
18270 })(i);
18271 }
18272 })();
18273 (function randomizeWatermarks() {
18274 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18275 var placed = [];
18276 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; }
18277 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]; }
18278 var half = Math.floor(wms.length/2);
18279 wms.forEach(function(img, i) {
18280 var pos = pick(i < half);
18281 var w = Math.floor(Math.random()*60+80);
18282 var rot = (Math.random()*40-20).toFixed(1);
18283 var op = (Math.random()*0.08+0.05).toFixed(2);
18284 var animDur = (Math.random()*6+5).toFixed(1);
18285 var animDelay = (Math.random()*10).toFixed(1);
18286 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';
18287 });
18288 })();
18289 </script>
18290 <script nonce="{{ csp_nonce }}">
18291 (function(){
18292 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'}];
18293 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);});}
18294 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18295 function init(){
18296 var btn=document.getElementById('settings-btn');if(!btn)return;
18297 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18298 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>';
18299 document.body.appendChild(m);
18300 var g=document.getElementById('scheme-grid');
18301 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);});
18302 var cl=document.getElementById('settings-close');
18303 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);
18304 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');});
18305 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18306 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18307 }
18308 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18309 }());
18310 </script>
18311 <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>
18312</body>
18313</html>
18314"##,
18315 ext = "html"
18316)]
18317struct ErrorTemplate {
18318 message: String,
18319 last_report_url: Option<String>,
18321 last_report_label: Option<String>,
18323 run_id: Option<String>,
18325 error_code: Option<u16>,
18327 csp_nonce: String,
18328 version: &'static str,
18329}
18330
18331#[derive(Template)]
18334#[template(
18335 source = r##"
18336<!doctype html>
18337<html lang="en">
18338<head>
18339 <meta charset="utf-8">
18340 <meta name="viewport" content="width=device-width, initial-scale=1">
18341 <title>OxideSLOC | Locate Scan Files</title>
18342 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18343 <style nonce="{{ csp_nonce }}">
18344 :root {
18345 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18346 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18347 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18348 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18349 }
18350 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18351 *{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;}
18352 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18353 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18354 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18355 .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);}
18356 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18357 .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));}
18358 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18359 .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;}
18360 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18361 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
18362 @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;}}
18363 .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;}
18364 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18365 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18366 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18367 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18368 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18369 .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;}
18370 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18371 .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);}
18372 .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;}
18373 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18374 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18375 .settings-modal-body{padding:14px 16px 16px;}
18376 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18377 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18378 .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;}
18379 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18380 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18381 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18382 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18383 .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;}
18384 .tz-select:focus{border-color:var(--oxide);}
18385 .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18386 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18387 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18388 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
18389 .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;}
18390 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18391 .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;}
18392 .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;}
18393 .btn-secondary:hover{background:var(--line);}
18394 .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;}
18395 .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;}
18396 .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;}
18397 @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));}}
18398 .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;}
18399 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
18400 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
18401 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
18402 .relocate-row{display:flex;gap:8px;align-items:stretch;}
18403 .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;}
18404 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
18405 body.dark-theme .relocate-input{background:var(--surface-2);}
18406 </style>
18407</head>
18408<body>
18409 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18416 </div>
18417 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18418 <div class="top-nav">
18419 <div class="top-nav-inner">
18420 <a class="brand" href="/">
18421 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18422 <div class="brand-copy">
18423 <div class="brand-title">OxideSLOC</div>
18424 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18425 </div>
18426 </a>
18427 <div class="nav-right">
18428 <a class="nav-pill" href="/">Home</a>
18429 <div class="nav-dropdown">
18430 <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>
18431 <div class="nav-dropdown-menu">
18432 <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>
18433 </div>
18434 </div>
18435 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
18436 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18437 <div class="nav-dropdown">
18438 <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>
18439 <div class="nav-dropdown-menu">
18440 <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>
18441 </div>
18442 </div>
18443 <div class="server-status-wrap" id="server-status-wrap">
18444 <div class="nav-pill server-online-pill" id="server-status-pill">
18445 <span class="status-dot" id="status-dot"></span>
18446 <span id="server-status-label">Server</span>
18447 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18448 </div>
18449 <div class="server-status-tip">
18450 OxideSLOC is running — accessible on your network.
18451 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18452 </div>
18453 </div>
18454 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18455 <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>
18456 </button>
18457 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18458 <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>
18459 <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>
18460 </button>
18461 </div>
18462 </div>
18463 </div>
18464
18465 <div class="page">
18466 <div class="panel">
18467 <h1>Scan Files Moved</h1>
18468 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
18469 <div class="error-box">{{ message }}</div>
18470 <div class="relocate-section">
18471 <h2>Locate Scan Output</h2>
18472 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
18473 <form method="post" action="/relocate-scan">
18474 <input type="hidden" name="run_id" value="{{ run_id }}">
18475 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
18476 <div class="relocate-row">
18477 <input type="text" id="relocate-folder" name="folder_path"
18478 value="{{ folder_hint }}"
18479 placeholder="Path to folder containing scan output..."
18480 class="relocate-input" autocomplete="off" spellcheck="false">
18481 {% if !server_mode %}
18482 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
18483 {% endif %}
18484 </div>
18485 <div style="margin-top:12px;">
18486 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
18487 </div>
18488 </form>
18489 </div>
18490 <div class="actions">
18491 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
18492 <a class="btn-secondary" href="/view-reports">View Reports</a>
18493 </div>
18494 </div>
18495 </div>
18496 <script nonce="{{ csp_nonce }}">
18497 (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");});})();
18498 (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);}})();
18499 (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';});})();
18500 </script>
18501 <script nonce="{{ csp_nonce }}">
18502 (function(){
18503 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'}];
18504 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);});}
18505 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18506 function init(){
18507 var btn=document.getElementById('settings-btn');if(!btn)return;
18508 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18509 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>';
18510 document.body.appendChild(m);
18511 var g=document.getElementById('scheme-grid');
18512 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);});
18513 var cl=document.getElementById('settings-close');
18514 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);
18515 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');});
18516 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18517 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18518 }
18519 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18520 }());
18521 (function(){
18522 var btn=document.getElementById('browse-relocate-btn');
18523 if(!btn)return;
18524 btn.addEventListener('click',function(){
18525 btn.disabled=true;btn.textContent='...';
18526 var inp=document.getElementById('relocate-folder');
18527 var hint=inp?inp.value:'';
18528 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
18529 .then(function(r){return r.ok?r.json():{cancelled:true};})
18530 .then(function(d){
18531 btn.disabled=false;btn.textContent='Browse…';
18532 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
18533 })
18534 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
18535 });
18536 }());
18537 </script>
18538 <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>
18539</body>
18540</html>
18541"##,
18542 ext = "html"
18543)]
18544struct RelocateScanTemplate {
18545 message: String,
18546 run_id: String,
18547 folder_hint: String,
18548 redirect_url: String,
18549 server_mode: bool,
18550 csp_nonce: String,
18551 version: &'static str,
18552}
18553
18554#[derive(Template)]
18557#[template(
18558 source = r##"
18559<!doctype html>
18560<html lang="en">
18561<head>
18562 <meta charset="utf-8">
18563 <meta name="viewport" content="width=device-width, initial-scale=1">
18564 <title>OxideSLOC | View Reports</title>
18565 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18566 <style nonce="{{ csp_nonce }}">
18567 :root {
18568 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18569 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18570 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18571 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18572 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
18573 }
18574 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; }
18575 *{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;}
18576 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18577 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18578 .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);}
18579 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18580 .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));}
18581 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18582 .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;}
18583 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18584 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18585 @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; } }
18586 .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;}
18587 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18588 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18589 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18590 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18591 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18592 .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;}
18593 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18594 .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);}
18595 .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;}
18596 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18597 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18598 .settings-modal-body{padding:14px 16px 16px;}
18599 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18600 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18601 .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;}
18602 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18603 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18604 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18605 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18606 .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;}
18607 .tz-select:focus{border-color:var(--oxide);}
18608 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18609 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18610 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18611 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18612 .panel-meta{font-size:13px;color:var(--muted);}
18613 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18614 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18615 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18616 .per-page-label{font-size:13px;color:var(--muted);}
18617 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;}
18618 .filter-input{min-width:180px;cursor:text;}
18619 .table-wrap{width:100%;overflow-x:auto;}
18620 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
18621 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;}
18622 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18623 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18624 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18625 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18626 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18627 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18628 tr:last-child td{border-bottom:none;}
18629 tr:hover td{background:var(--surface-2);}
18630 .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);}
18631 .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);}
18632 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18633 .metric-num{font-weight:700;color:var(--text);}
18634 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18635 .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;}
18636 .btn:hover{background:var(--line);}
18637 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18638 .btn.primary:hover{opacity:.9;}
18639 .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;}
18640 .btn-back:hover{background:var(--line);}
18641 .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;}
18642 .export-btn:hover{background:var(--line);}
18643 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18644 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18645 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18646 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18647 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18648 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18649 .pagination-info{font-size:13px;color:var(--muted);}
18650 .pagination-btns{display:flex;gap:6px;}
18651 .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;}
18652 .pg-btn:hover:not(:disabled){background:var(--line);}
18653 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18654 .pg-btn:disabled{opacity:.35;cursor:default;}
18655 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18656 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18657 .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;}
18658 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18659 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18660 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18661 .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);}
18662 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18663 .stat-chip:hover .stat-chip-tip{opacity:1;}
18664 .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;}
18665 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18666 .site-footer a{color:var(--muted);}
18667 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18668 .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%;}
18669 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18670 .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;}
18671 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18672 .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;}
18673 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18674 .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;}
18675 .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;}
18676 .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;}
18677 @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));}}
18678 .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;}
18679 .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;}
18680 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18681 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18682 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18683 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18684 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18685 .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;}
18686 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18687 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18688 .watched-chip-rm:hover{color:var(--oxide);}
18689 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18690 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18691 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18692 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18693 .rpt-btn{min-width:58px;justify-content:center;}
18694 .flex-row{display:flex;align-items:center;gap:8px;}
18695 .report-cell{overflow:visible;white-space:normal;}
18696 #history-table col:nth-child(1){width:185px;}
18697 #history-table col:nth-child(2){width:220px;}
18698 #history-table col:nth-child(3){width:100px;}
18699 #history-table col:nth-child(4){width:72px;}
18700 #history-table col:nth-child(5){width:82px;}
18701 #history-table col:nth-child(6){width:82px;}
18702 #history-table col:nth-child(7){width:65px;}
18703 #history-table col:nth-child(8){width:90px;}
18704 #history-table col:nth-child(9){width:85px;}
18705 #history-table col:nth-child(10){width:115px;}
18706 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18707 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18708 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18709 .submod-details summary::-webkit-details-marker{display:none;}
18710.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18711 .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;}
18712 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18713 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18714 </style>
18715</head>
18716<body>
18717 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18724 </div>
18725 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18726 <div class="top-nav">
18727 <div class="top-nav-inner">
18728 <a class="brand" href="/">
18729 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18730 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18731 </a>
18732 <div class="nav-right">
18733 <a class="nav-pill" href="/">Home</a>
18734 <div class="nav-dropdown">
18735 <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>
18736 <div class="nav-dropdown-menu">
18737 <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>
18738 </div>
18739 </div>
18740 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18741 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18742 <div class="nav-dropdown">
18743 <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>
18744 <div class="nav-dropdown-menu">
18745 <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>
18746 </div>
18747 </div>
18748 <div class="server-status-wrap" id="server-status-wrap">
18749 <div class="nav-pill server-online-pill" id="server-status-pill">
18750 <span class="status-dot" id="status-dot"></span>
18751 <span id="server-status-label">Server</span>
18752 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18753 </div>
18754 <div class="server-status-tip">
18755 OxideSLOC is running — accessible on your network.
18756 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18757 </div>
18758 </div>
18759 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18760 <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>
18761 </button>
18762 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18763 <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>
18764 <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>
18765 </button>
18766 </div>
18767 </div>
18768 </div>
18769
18770 <div class="page">
18771 {% if let Some(err) = browse_error %}
18772 <div class="toast-error">
18773 <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>
18774 {{ err }}
18775 </div>
18776 {% endif %}
18777 {% if linked_count > 0 %}
18778 <div class="toast-success">
18779 <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>
18780 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18781 </div>
18782 {% endif %}
18783 <div class="watched-bar">
18784 <div class="watched-bar-left">
18785 <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>
18786 <span class="watched-label">Watched Folders</span>
18787 <div class="watched-chips">
18788 {% if server_mode %}
18789 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18790 {% else %}
18791 {% for dir in watched_dirs %}
18792 <span class="watched-chip">
18793 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18794 <form method="POST" action="/watched-dirs/remove" style="display:contents">
18795 <input type="hidden" name="folder_path" value="{{ dir }}">
18796 <input type="hidden" name="redirect_to" value="/view-reports">
18797 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
18798 </form>
18799 </span>
18800 {% endfor %}
18801 {% if watched_dirs.is_empty() %}
18802 <span class="watched-none">No folders watched — click Choose to add one</span>
18803 {% endif %}
18804 {% endif %}
18805 </div>
18806 </div>
18807 {% if !server_mode %}
18808 <div class="watched-bar-right">
18809 <button type="button" class="btn" id="add-watched-btn">
18810 <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>
18811 Choose
18812 </button>
18813 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18814 <input type="hidden" name="redirect_to" value="/view-reports">
18815 <button type="submit" class="btn">↻ Refresh</button>
18816 </form>
18817 </div>
18818 {% endif %}
18819 </div>
18820 {% if total_scans > 0 %}
18821 <div class="summary-strip">
18822 <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>
18823 <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>
18824 <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>
18825 <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>
18826 </div>
18827 {% endif %}
18828
18829 <section class="panel">
18830 <div class="panel-header">
18831 <div>
18832 <h1>View Reports</h1>
18833 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18834 {% 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 %}
18835 </div>
18836 <div class="flex-row">
18837 <button type="button" class="export-btn" id="export-csv-btn">
18838 <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>
18839 Export CSV
18840 </button>
18841 <button type="button" class="export-btn" id="export-xls-btn">
18842 <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>
18843 Export Excel
18844 </button>
18845 </div>
18846 </div>
18847
18848 {% if entries.is_empty() %}
18849 <div class="empty-state">
18850 <strong>No reports with viewable HTML yet</strong>
18851 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.
18852 </div>
18853 {% else %}
18854 <div class="filter-row">
18855 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
18856 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18857 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
18858 </div>
18859 <div class="table-wrap">
18860 <table id="history-table">
18861 <colgroup>
18862 <col><col><col><col><col><col><col><col><col><col>
18863 </colgroup>
18864 <thead>
18865 <tr id="history-thead">
18866 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18867 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18868 <th>Run ID<div class="col-resize-handle"></div></th>
18869 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18870 <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>
18871 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18872 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18873 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18874 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18875 <th>Report<div class="col-resize-handle"></div></th>
18876 </tr>
18877 </thead>
18878 <tbody id="history-tbody">
18879 {% for entry in entries %}
18880 <tr class="history-row" data-run="{{ entry.run_id }}"
18881 data-timestamp="{{ entry.timestamp }}"
18882 data-project="{{ entry.project_label }}"
18883 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18884 data-skipped="{{ entry.files_skipped }}"
18885 data-comments="{{ entry.comment_lines }}"
18886 data-blank="{{ entry.blank_lines }}"
18887 data-branch="{{ entry.git_branch }}"
18888 data-commit="{{ entry.git_commit }}"
18889 data-html-url="/runs/html/{{ entry.run_id }}">
18890 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18891 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18892 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18893 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18894 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18895 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18896 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18897 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
18898 <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>
18899 <td class="report-cell">
18900 <div class="actions-cell">
18901 {% 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 %}
18902 {% 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 %}
18903 </div>
18904 {% if !entry.submodule_links.is_empty() %}
18905 <details class="submod-details">
18906 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
18907 <div class="submod-link-list">
18908 {% for sub in entry.submodule_links %}
18909 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18910 {% endfor %}
18911 </div>
18912 </details>
18913 {% endif %}
18914 </td>
18915 </tr>
18916 {% endfor %}
18917 </tbody>
18918 </table>
18919 </div>
18920 <div class="pagination">
18921 <span class="pagination-info" id="pagination-info"></span>
18922 <div class="pagination-btns" id="pagination-btns"></div>
18923 <div class="flex-row">
18924 <span class="per-page-label">Show</span>
18925 <select class="per-page" id="per-page-sel">
18926 <option value="10">10 per page</option>
18927 <option value="25" selected>25 per page</option>
18928 <option value="50">50 per page</option>
18929 <option value="100">100 per page</option>
18930 </select>
18931 <span class="per-page-label" id="page-range-label"></span>
18932 </div>
18933 </div>
18934 {% endif %}
18935 </section>
18936 </div>
18937
18938 <footer class="site-footer">
18939 local code analysis - metrics, history and reports
18940 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
18941 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18942 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18943 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18944 · <a href="/api-docs" rel="noopener">REST API</a>
18945 </footer>
18946
18947 <script nonce="{{ csp_nonce }}">
18948 (function () {
18949 // ── Theme ──────────────────────────────────────────────────────────────
18950 var storageKey = 'oxide-sloc-theme';
18951 var body = document.body;
18952 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18953 var toggle = document.getElementById('theme-toggle');
18954 if (toggle) toggle.addEventListener('click', function () {
18955 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18956 body.classList.toggle('dark-theme', next === 'dark');
18957 try { localStorage.setItem(storageKey, next); } catch(e) {}
18958 });
18959
18960 // ── State ─────────────────────────────────────────────────────────────
18961 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18962 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18963 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18964
18965 // Aggregate stats from first (most recent) row
18966 if (allRows.length) {
18967 var first = allRows[0];
18968 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();}
18969 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>':'');}
18970 setChipVal('agg-code', first.dataset.code);
18971 setChipVal('agg-files', first.dataset.files);
18972 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
18973 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
18974 }
18975
18976 // ── Branch filter population ──────────────────────────────────────────
18977 (function() {
18978 var branches = {};
18979 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18980 var sel = document.getElementById('branch-filter');
18981 if (sel) Object.keys(branches).sort().forEach(function(b) {
18982 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18983 });
18984 })();
18985
18986 // ── Filter ────────────────────────────────────────────────────────────
18987 function getFilteredRows() {
18988 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18989 var branch = ((document.getElementById('branch-filter') || {}).value || '');
18990 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18991 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18992 if (branch && (r.dataset.branch || '') !== branch) return false;
18993 return true;
18994 });
18995 }
18996
18997 // ── Pagination ────────────────────────────────────────────────────────
18998 function renderPage() {
18999 var filtered = getFilteredRows();
19000 var total = filtered.length;
19001 var totalPages = Math.max(1, Math.ceil(total / perPage));
19002 currentPage = Math.min(currentPage, totalPages);
19003 var start = (currentPage - 1) * perPage;
19004 var end = Math.min(start + perPage, total);
19005 var shown = {};
19006 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19007 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
19008 r.style.display = shown[r.dataset.run] ? '' : 'none';
19009 });
19010 var rl = document.getElementById('page-range-label');
19011 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19012 var info = document.getElementById('pagination-info');
19013 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19014 var btns = document.getElementById('pagination-btns');
19015 if (!btns) return;
19016 btns.innerHTML = '';
19017 function makeBtn(lbl, pg, active, disabled) {
19018 var b = document.createElement('button');
19019 b.className = 'pg-btn' + (active ? ' active' : '');
19020 b.textContent = lbl; b.disabled = disabled;
19021 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19022 return b;
19023 }
19024 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19025 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19026 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19027 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19028 }
19029
19030 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19031 window.applyFilters = function() { currentPage = 1; renderPage(); };
19032
19033 // ── Sorting ───────────────────────────────────────────────────────────
19034 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
19035 function doSort(col, type, order) {
19036 var tbody = document.getElementById('history-tbody');
19037 if (!tbody) return;
19038 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19039 rows.sort(function(a, b) {
19040 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19041 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19042 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19043 return va < vb ? 1 : va > vb ? -1 : 0;
19044 });
19045 rows.forEach(function(r) { tbody.appendChild(r); });
19046 currentPage = 1; renderPage();
19047 }
19048 sortHeaders.forEach(function(th) {
19049 th.addEventListener('click', function(e) {
19050 if (e.target.classList.contains('col-resize-handle')) return;
19051 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19052 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19053 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19054 th.classList.add('sort-' + sortOrder);
19055 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19056 doSort(col, type, sortOrder);
19057 });
19058 });
19059
19060 // ── Column resize ─────────────────────────────────────────────────────
19061 (function() {
19062 var table = document.getElementById('history-table');
19063 if (!table) return;
19064 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19065 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
19066 ths.forEach(function(th, i) {
19067 var handle = th.querySelector('.col-resize-handle');
19068 if (!handle || !cols[i]) return;
19069 var startX, startW;
19070 handle.addEventListener('mousedown', function(e) {
19071 e.stopPropagation(); e.preventDefault();
19072 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19073 handle.classList.add('dragging');
19074 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19075 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19076 document.addEventListener('mousemove', onMove);
19077 document.addEventListener('mouseup', onUp);
19078 });
19079 });
19080 })();
19081
19082 // ── Reset view ────────────────────────────────────────────────────────
19083 window.resetView = function() {
19084 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19085 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19086 sortCol = null; sortOrder = 'asc';
19087 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19088 var tbody = document.getElementById('history-tbody');
19089 if (tbody) {
19090 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19091 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19092 rows.forEach(function(r) { tbody.appendChild(r); });
19093 }
19094 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19095 var table = document.getElementById('history-table');
19096 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
19097 currentPage = 1; renderPage();
19098 };
19099
19100 renderPage();
19101
19102 // ── Export helpers ────────────────────────────────────────────────────
19103 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
19104 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
19105 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);}
19106 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;');}
19107 function slocXlsx(fname,sheet,hdrs,rows){
19108 var enc=new TextEncoder();
19109 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;}
19110 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;}
19111 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
19112 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
19113 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
19114 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;}
19115 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];}
19116 var rx='<row r="1">';
19117 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
19118 rx+='</row>';
19119 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>';});
19120 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
19121 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>';
19122 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>';
19123 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>';
19124 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>',
19125 '_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>',
19126 '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>',
19127 '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>',
19128 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
19129 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'];
19130 var zparts=[],zcds=[],zoff=0,znf=0;
19131 order.forEach(function(name){
19132 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
19133 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]);
19134 var entry=new Uint8Array(lha.length+nb.length+sz);
19135 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
19136 zparts.push(entry);
19137 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));
19138 var cde=new Uint8Array(cda.length+nb.length);
19139 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
19140 zcds.push(cde);zoff+=entry.length;znf++;
19141 });
19142 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
19143 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]);
19144 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
19145 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
19146 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
19147 zout.set(new Uint8Array(ea),zpos);
19148 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
19149 }
19150
19151 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
19152 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;}
19153 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
19154 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
19155
19156 var csvBtn = document.getElementById('export-csv-btn');
19157 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
19158 var xlsBtn = document.getElementById('export-xls-btn');
19159 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
19160
19161 // ── Remaining CSP-safe event bindings ────────────────────────────────
19162 (function wireEvents() {
19163 var el;
19164 el = document.getElementById('reset-view-btn');
19165 if (el) el.addEventListener('click', window.resetView);
19166 el = document.getElementById('project-filter');
19167 if (el) el.addEventListener('input', window.applyFilters);
19168 el = document.getElementById('branch-filter');
19169 if (el) el.addEventListener('change', window.applyFilters);
19170 el = document.getElementById('per-page-sel');
19171 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
19172 el = document.getElementById('add-watched-btn');
19173 if (el) el.addEventListener('click', function() {
19174 fetch('/pick-directory?kind=reports')
19175 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19176 .then(function(data) {
19177 if (!data.cancelled && data.selected_path) {
19178 var form = document.createElement('form');
19179 form.method = 'POST';
19180 form.action = '/watched-dirs/add';
19181 var ri = document.createElement('input');
19182 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19183 var fi = document.createElement('input');
19184 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19185 form.appendChild(ri); form.appendChild(fi);
19186 document.body.appendChild(form);
19187 form.submit();
19188 }
19189 })
19190 .catch(function(e) { alert('Could not open folder picker: ' + e); });
19191 });
19192 })();
19193
19194 (function randomizeWatermarks() {
19195 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19196 if (!wms.length) return;
19197 var placed = [];
19198 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;}
19199 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];}
19200 var half=Math.floor(wms.length/2);
19201 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;});
19202 })();
19203
19204 (function spawnCodeParticles() {
19205 var container = document.getElementById('code-particles');
19206 if (!container) return;
19207 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'];
19208 for (var i = 0; i < 38; i++) {
19209 (function(idx) {
19210 var el = document.createElement('span');
19211 el.className = 'code-particle';
19212 el.textContent = snippets[idx % snippets.length];
19213 var left = Math.random() * 94 + 2;
19214 var top = Math.random() * 88 + 6;
19215 var dur = (Math.random() * 10 + 9).toFixed(1);
19216 var delay = (Math.random() * 18).toFixed(1);
19217 var rot = (Math.random() * 26 - 13).toFixed(1);
19218 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19219 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';
19220 container.appendChild(el);
19221 })(i);
19222 }
19223 })();
19224 })();
19225 </script>
19226 <script nonce="{{ csp_nonce }}">
19227 (function(){
19228 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'}];
19229 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);});}
19230 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19231 function init(){
19232 var btn=document.getElementById('settings-btn');if(!btn)return;
19233 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19234 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>';
19235 document.body.appendChild(m);
19236 var g=document.getElementById('scheme-grid');
19237 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);});
19238 var cl=document.getElementById('settings-close');
19239 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);
19240 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');});
19241 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19242 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19243 }
19244 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19245 }());
19246 </script>
19247 <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>
19248</body>
19249</html>
19250"##,
19251 ext = "html"
19252)]
19253struct HistoryTemplate {
19254 version: &'static str,
19255 entries: Vec<HistoryEntryRow>,
19256 total_scans: usize,
19257 linked_count: usize,
19258 browse_error: Option<String>,
19259 watched_dirs: Vec<String>,
19260 csp_nonce: String,
19261 server_mode: bool,
19262}
19263
19264#[derive(Template)]
19267#[template(
19268 source = r##"
19269<!doctype html>
19270<html lang="en">
19271<head>
19272 <meta charset="utf-8">
19273 <meta name="viewport" content="width=device-width, initial-scale=1">
19274 <title>OxideSLOC | Compare Scans</title>
19275 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19276 <style nonce="{{ csp_nonce }}">
19277 :root {
19278 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
19279 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19280 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19281 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19282 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
19283 }
19284 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19285 *{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;}
19286 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19287 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19288 .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);}
19289 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19290 .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));}
19291 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19292 .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;}
19293 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19294 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19295 @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; } }
19296 .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;}
19297 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19298 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19299 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19300 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19301 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19302 .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;}
19303 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19304 .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);}
19305 .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;}
19306 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19307 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19308 .settings-modal-body{padding:14px 16px 16px;}
19309 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19310 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19311 .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;}
19312 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19313 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19314 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19315 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19316 .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;}
19317 .tz-select:focus{border-color:var(--oxide);}
19318 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19319 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19320 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
19321 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
19322 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
19323 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
19324 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
19325 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
19326 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
19327 .per-page-label{font-size:13px;color:var(--muted);}
19328 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;}
19329 .filter-input{min-width:180px;cursor:text;}
19330 .table-wrap{width:100%;overflow-x:auto;}
19331 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19332 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;}
19333 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19334 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19335 #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;}
19336 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
19337 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
19338 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
19339 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
19340 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
19341 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
19342 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
19343 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
19344 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
19345 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19346 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19347 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19348 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19349 tr:last-child td{border-bottom:none;}
19350 tr.selected td{background:var(--sel-bg);}
19351 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
19352 tr:hover:not(.selected) td{background:var(--surface-2);}
19353 tr{cursor:pointer;}
19354 .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);}
19355 .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);}
19356 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
19357 .metric-num{font-weight:700;color:var(--text);}
19358 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
19359 .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;}
19360 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
19361 .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;}
19362 .btn:hover{background:var(--line);}
19363 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
19364 .btn.primary:hover{opacity:.9;}
19365 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
19366 .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;}
19367 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
19368 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
19369 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
19370 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
19371 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
19372 .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;}
19373 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19374 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
19375 .watched-chip-rm:hover{color:var(--oxide);}
19376 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
19377 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
19378 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
19379 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
19380 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
19381 .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;}
19382 .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;}
19383 .btn-back:hover{background:var(--line);}
19384 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
19385 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
19386 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19387 .pagination-info{font-size:13px;color:var(--muted);}
19388 .pagination-btns{display:flex;gap:6px;}
19389 .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;}
19390 .pg-btn:hover:not(:disabled){background:var(--line);}
19391 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19392 .pg-btn:disabled{opacity:.35;cursor:default;}
19393 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
19394 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19395 .site-footer a{color:var(--muted);}
19396 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
19397 .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;}
19398 .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;}
19399 .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;}
19400 @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));}}
19401 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
19402 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
19403 .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;}
19404 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
19405 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
19406 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
19407 .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);}
19408 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
19409 .stat-chip:hover .stat-chip-tip{opacity:1;}
19410 .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;}
19411 .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;}
19412 .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%;}
19413 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
19414 .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;}
19415 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
19416 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
19417 .hidden{display:none!important;}
19418 .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%;}
19419 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
19420 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
19421 .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;}
19422 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19423 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
19424 .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;}
19425 .scope-option:hover{background:var(--line);}
19426 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
19427 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
19428 .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;}
19429 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
19430 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
19431 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
19432 .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;}
19433 </style>
19434</head>
19435<body>
19436 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19443 </div>
19444 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19445 <div class="top-nav">
19446 <div class="top-nav-inner">
19447 <a class="brand" href="/">
19448 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19449 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
19450 </a>
19451 <div class="nav-right">
19452 <a class="nav-pill" href="/">Home</a>
19453 <div class="nav-dropdown">
19454 <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>
19455 <div class="nav-dropdown-menu">
19456 <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>
19457 </div>
19458 </div>
19459 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19460 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19461 <div class="nav-dropdown">
19462 <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>
19463 <div class="nav-dropdown-menu">
19464 <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>
19465 </div>
19466 </div>
19467 <div class="server-status-wrap" id="server-status-wrap">
19468 <div class="nav-pill server-online-pill" id="server-status-pill">
19469 <span class="status-dot" id="status-dot"></span>
19470 <span id="server-status-label">Server</span>
19471 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19472 </div>
19473 <div class="server-status-tip">
19474 OxideSLOC is running — accessible on your network.
19475 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19476 </div>
19477 </div>
19478 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19479 <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>
19480 </button>
19481 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19482 <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>
19483 <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>
19484 </button>
19485 </div>
19486 </div>
19487 </div>
19488
19489 <div class="page">
19490 <div class="watched-bar">
19491 <div class="watched-bar-left">
19492 <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>
19493 <span class="watched-label">Watched Folders</span>
19494 <div class="watched-chips">
19495 {% if server_mode %}
19496 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
19497 {% else %}
19498 {% for dir in watched_dirs %}
19499 <span class="watched-chip">
19500 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
19501 <form method="POST" action="/watched-dirs/remove" style="display:contents">
19502 <input type="hidden" name="folder_path" value="{{ dir }}">
19503 <input type="hidden" name="redirect_to" value="/compare-scans">
19504 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
19505 </form>
19506 </span>
19507 {% endfor %}
19508 {% if watched_dirs.is_empty() %}
19509 <span class="watched-none">No folders watched — click Choose to add one</span>
19510 {% endif %}
19511 {% endif %}
19512 </div>
19513 </div>
19514 {% if !server_mode %}
19515 <div class="watched-bar-right">
19516 <button type="button" class="btn" id="add-watched-btn">
19517 <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>
19518 Choose
19519 </button>
19520 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
19521 <input type="hidden" name="redirect_to" value="/compare-scans">
19522 <button type="submit" class="btn">↻ Refresh</button>
19523 </form>
19524 </div>
19525 {% endif %}
19526 </div>
19527 {% if total_scans > 0 %}
19528 <div class="summary-strip">
19529 <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>
19530 <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>
19531 <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>
19532 <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>
19533 </div>
19534 {% endif %}
19535 <section class="panel">
19536 <div class="panel-header">
19537 <div>
19538 <h1>Compare Scans</h1>
19539 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
19540 </div>
19541 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
19542 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
19543 <button class="btn primary" id="compare-btn" disabled>
19544 <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>
19545 Compare <span class="sel-count" id="sel-count">0/2</span>
19546 </button>
19547 </div>
19548 </div>
19549 </div>
19550
19551 {% if entries.is_empty() %}
19552 <div class="empty-state">
19553 <strong>No scans yet</strong>
19554 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.
19555 </div>
19556 {% else %}
19557 <div class="filter-row">
19558 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
19559 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
19560 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
19561 </div>
19562 <div class="scope-panel hidden" id="scope-panel">
19563 <div class="scope-panel-label">
19564 <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>
19565 Compare scope — choose what to include
19566 </div>
19567 <div class="scope-options" id="scope-options"></div>
19568 </div>
19569 {% if total_scans > 0 %}
19570 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
19571 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
19572 <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>
19573 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
19574 </div>
19575 </div>
19576 {% endif %}
19577 <div class="table-wrap">
19578 <table id="compare-table">
19579 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
19580 <thead>
19581 <tr id="compare-thead">
19582 <th><div class="col-resize-handle"></div></th>
19583 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19584 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19585 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
19586 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19587 <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>
19588 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19589 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19590 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19591 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19592 <th>Submodules<div class="col-resize-handle"></div></th>
19593 </tr>
19594 </thead>
19595 <tbody id="compare-tbody">
19596 {% for entry in entries %}
19597 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
19598 data-timestamp="{{ entry.timestamp }}"
19599 data-project="{{ entry.project_label }}"
19600 data-files="{{ entry.files_analyzed }}"
19601 data-code="{{ entry.code_lines }}"
19602 data-comments="{{ entry.comment_lines }}"
19603 data-blank="{{ entry.blank_lines }}"
19604 data-branch="{{ entry.git_branch }}"
19605 data-commit="{{ entry.git_commit }}"
19606 data-submodules="{{ entry.submodule_names_csv }}">
19607 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
19608 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
19609 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
19610 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
19611 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
19612 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
19613 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
19614 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
19615 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
19616 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
19617 <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>
19618 </tr>
19619 {% endfor %}
19620 </tbody>
19621 </table>
19622 </div>
19623 <div class="pagination">
19624 <span class="pagination-info" id="pagination-info"></span>
19625 <div class="pagination-btns" id="pagination-btns"></div>
19626 <div class="flex-row">
19627 <span class="per-page-label">Show</span>
19628 <select class="per-page" id="per-page-sel">
19629 <option value="10">10 per page</option>
19630 <option value="25" selected>25 per page</option>
19631 <option value="50">50 per page</option>
19632 <option value="100">100 per page</option>
19633 </select>
19634 <span class="per-page-label" id="page-range-label"></span>
19635 </div>
19636 </div>
19637 {% endif %}
19638 </section>
19639 </div>
19640
19641 <footer class="site-footer">
19642 local code analysis - metrics, history and reports
19643 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19644 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19645 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19646 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19647 · <a href="/api-docs" rel="noopener">REST API</a>
19648 </footer>
19649
19650 <script nonce="{{ csp_nonce }}">
19651 (function () {
19652 // ── Theme ──────────────────────────────────────────────────────────────
19653 var storageKey = 'oxide-sloc-theme';
19654 var body = document.body;
19655 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19656 var toggle = document.getElementById('theme-toggle');
19657 if (toggle) toggle.addEventListener('click', function () {
19658 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19659 body.classList.toggle('dark-theme', next === 'dark');
19660 try { localStorage.setItem(storageKey, next); } catch(e) {}
19661 });
19662
19663 // ── State ─────────────────────────────────────────────────────────────
19664 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19665 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19666 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19667
19668 // ── Stat chips ────────────────────────────────────────────────────────
19669 (function() {
19670 var projects = {}, latestTs = '', latestRow = null;
19671 allRows.forEach(function(r) {
19672 var p = r.dataset.project || ''; if (p) projects[p] = true;
19673 var ts = r.dataset.timestamp || '';
19674 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19675 });
19676 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();}
19677 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>':'');}
19678 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19679 if (latestRow) {
19680 setChipVal('agg-code', latestRow.dataset.code);
19681 setChipVal('agg-files', latestRow.dataset.files);
19682 }
19683 })();
19684
19685 // ── Branch filter population ──────────────────────────────────────────
19686 (function() {
19687 var branches = {};
19688 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19689 var sel = document.getElementById('branch-filter');
19690 if (sel) Object.keys(branches).sort().forEach(function(b) {
19691 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19692 });
19693 })();
19694
19695 // ── Filter ────────────────────────────────────────────────────────────
19696 function getFilteredRows() {
19697 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19698 var branch = ((document.getElementById('branch-filter') || {}).value || '');
19699 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19700 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19701 if (branch && (r.dataset.branch || '') !== branch) return false;
19702 return true;
19703 });
19704 }
19705
19706 // ── Pagination ────────────────────────────────────────────────────────
19707 function renderPage() {
19708 var filtered = getFilteredRows();
19709 var total = filtered.length;
19710 var totalPages = Math.max(1, Math.ceil(total / perPage));
19711 currentPage = Math.min(currentPage, totalPages);
19712 var start = (currentPage - 1) * perPage;
19713 var end = Math.min(start + perPage, total);
19714 var shown = {};
19715 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19716 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19717 r.style.display = shown[r.dataset.run] ? '' : 'none';
19718 });
19719 var rl = document.getElementById('page-range-label');
19720 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19721 var info = document.getElementById('pagination-info');
19722 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19723 var btns = document.getElementById('pagination-btns');
19724 if (!btns) return;
19725 btns.innerHTML = '';
19726 function makeBtn(lbl, pg, active, disabled) {
19727 var b = document.createElement('button');
19728 b.className = 'pg-btn' + (active ? ' active' : '');
19729 b.textContent = lbl; b.disabled = disabled;
19730 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19731 return b;
19732 }
19733 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19734 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19735 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19736 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19737 }
19738
19739 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19740 window.applyFilters = function() { currentPage = 1; renderPage(); };
19741
19742 // ── Sorting ───────────────────────────────────────────────────────────
19743 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19744 function doSort(col, type, order) {
19745 var tbody = document.getElementById('compare-tbody');
19746 if (!tbody) return;
19747 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19748 rows.sort(function(a, b) {
19749 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19750 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19751 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19752 return va < vb ? 1 : va > vb ? -1 : 0;
19753 });
19754 rows.forEach(function(r) { tbody.appendChild(r); });
19755 currentPage = 1; renderPage();
19756 }
19757 sortHeaders.forEach(function(th) {
19758 th.addEventListener('click', function(e) {
19759 if (e.target.classList.contains('col-resize-handle')) return;
19760 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19761 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19762 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19763 th.classList.add('sort-' + sortOrder);
19764 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19765 doSort(col, type, sortOrder);
19766 });
19767 });
19768
19769 // Apply default sort (timestamp desc) on initial load
19770 (function() {
19771 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19772 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19773 })();
19774
19775 // ── Column resize ─────────────────────────────────────────────────────
19776 (function() {
19777 var table = document.getElementById('compare-table');
19778 if (!table) return;
19779 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19780 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19781 ths.forEach(function(th, i) {
19782 var handle = th.querySelector('.col-resize-handle');
19783 if (!handle || !cols[i]) return;
19784 var startX, startW;
19785 handle.addEventListener('mousedown', function(e) {
19786 e.stopPropagation(); e.preventDefault();
19787 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19788 handle.classList.add('dragging');
19789 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19790 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19791 document.addEventListener('mousemove', onMove);
19792 document.addEventListener('mouseup', onUp);
19793 });
19794 });
19795 })();
19796
19797 // ── Reset view ────────────────────────────────────────────────────────
19798 window.resetView = function() {
19799 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19800 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19801 sortCol = null; sortOrder = 'asc';
19802 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19803 var tbody = document.getElementById('compare-tbody');
19804 if (tbody) {
19805 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19806 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19807 rows.forEach(function(r) { tbody.appendChild(r); });
19808 }
19809 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19810 var table = document.getElementById('compare-table');
19811 currentPage = 1; renderPage();
19812 currentPage = 1; renderPage();
19813 };
19814
19815 renderPage();
19816
19817 // ── Row selection state ───────────────────────────────────────────────
19818 var selected = [];
19819 function updateCompareBtn() {
19820 var btn = document.getElementById('compare-btn');
19821 var cnt = document.getElementById('sel-count');
19822 if (!btn) return;
19823 btn.disabled = selected.length !== 2;
19824 if (cnt) cnt.textContent = selected.length + '/2';
19825 }
19826
19827 function toggleRow(row) {
19828 var vid = row.dataset.vid || row.dataset.run;
19829 var idx = selected.indexOf(vid);
19830 if (idx >= 0) {
19831 selected.splice(idx, 1);
19832 row.classList.remove('selected');
19833 var b = document.getElementById('badge-' + vid);
19834 if (b) b.textContent = '';
19835 } else {
19836 if (selected.length >= 2) return;
19837 selected.push(vid);
19838 row.classList.add('selected');
19839 }
19840 selected.forEach(function(v, i) {
19841 var b = document.getElementById('badge-' + v);
19842 if (b) b.textContent = i + 1;
19843 });
19844 updateCompareBtn();
19845 buildScopePanel();
19846 }
19847
19848 // ── Scope panel ───────────────────────────────────────────────────────
19849 var selectedScope = 'all';
19850
19851 function buildScopePanel() {
19852 var panel = document.getElementById('scope-panel');
19853 var opts = document.getElementById('scope-options');
19854 if (!panel || !opts) return;
19855 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19856
19857 // Collect union of submodules from both selected rows.
19858 var allSubs = {};
19859 selected.forEach(function(vid) {
19860 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19861 if (!row) return;
19862 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19863 });
19864 var subList = Object.keys(allSubs).sort();
19865 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19866
19867 panel.classList.remove('hidden');
19868 opts.innerHTML = '';
19869
19870 function makeOption(value, label, title) {
19871 var div = document.createElement('div');
19872 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19873 div.dataset.scopeValue = value;
19874 if (title) div.title = title;
19875 var radio = document.createElement('span');
19876 radio.className = 'scope-option-radio';
19877 var lbl = document.createElement('span');
19878 lbl.textContent = label;
19879 div.appendChild(radio);
19880 div.appendChild(lbl);
19881 div.addEventListener('click', function() {
19882 selectedScope = value;
19883 opts.querySelectorAll('.scope-option').forEach(function(o) {
19884 o.classList.toggle('selected', o.dataset.scopeValue === value);
19885 });
19886 });
19887 return div;
19888 }
19889
19890 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19891 var sep = document.createElement('span');
19892 sep.className = 'scope-option-sep';
19893 opts.appendChild(sep);
19894 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19895 subList.forEach(function(s) {
19896 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19897 });
19898 }
19899
19900 function doCompare() {
19901 if (selected.length !== 2) return;
19902 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19903 if (selectedScope === 'super') url += '&scope=super';
19904 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19905 window.location.href = url;
19906 }
19907
19908 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19909 var cbtn = document.getElementById('compare-btn');
19910 if (cbtn) cbtn.addEventListener('click', doCompare);
19911 var pfEl = document.getElementById('project-filter');
19912 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19913 var bfEl = document.getElementById('branch-filter');
19914 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19915 var rvBtn = document.getElementById('reset-view-btn');
19916 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19917 var ppSel = document.getElementById('per-page-sel');
19918 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19919
19920 var cmpTbody = document.getElementById('compare-tbody');
19921 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19922 var row = e.target.closest('.compare-row');
19923 if (row) toggleRow(row);
19924 });
19925
19926 (function randomizeWatermarks() {
19927 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19928 if (!wms.length) return;
19929 var placed = [];
19930 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;}
19931 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];}
19932 var half=Math.floor(wms.length/2);
19933 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;});
19934 })();
19935
19936 (function spawnCodeParticles() {
19937 var container = document.getElementById('code-particles');
19938 if (!container) return;
19939 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'];
19940 for (var i = 0; i < 38; i++) {
19941 (function(idx) {
19942 var el = document.createElement('span');
19943 el.className = 'code-particle';
19944 el.textContent = snippets[idx % snippets.length];
19945 var left = Math.random() * 94 + 2;
19946 var top = Math.random() * 88 + 6;
19947 var dur = (Math.random() * 10 + 9).toFixed(1);
19948 var delay = (Math.random() * 18).toFixed(1);
19949 var rot = (Math.random() * 26 - 13).toFixed(1);
19950 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19951 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';
19952 container.appendChild(el);
19953 })(i);
19954 }
19955 })();
19956
19957 // ── Watched folder picker ─────────────────────────────────────────────
19958 (function() {
19959 var btn = document.getElementById('add-watched-btn');
19960 if (!btn) return;
19961 btn.addEventListener('click', function() {
19962 fetch('/pick-directory?kind=reports')
19963 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19964 .then(function(data) {
19965 if (!data.cancelled && data.selected_path) {
19966 var form = document.createElement('form');
19967 form.method = 'POST';
19968 form.action = '/watched-dirs/add';
19969 var ri = document.createElement('input');
19970 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19971 var fi = document.createElement('input');
19972 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19973 form.appendChild(ri); form.appendChild(fi);
19974 document.body.appendChild(form);
19975 form.submit();
19976 }
19977 })
19978 .catch(function(e) { alert('Could not open folder picker: ' + e); });
19979 });
19980 })();
19981
19982 // ── Submodule chip truncation ─────────────────────────────────────────
19983 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19984 var chips = cell.querySelectorAll('.submod-chip');
19985 var MAX = 4;
19986 if (chips.length <= MAX) return;
19987 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19988 var badge = document.createElement('span');
19989 badge.className = 'submod-overflow-badge';
19990 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19991 badge.textContent = '+' + (chips.length - MAX) + ' more';
19992 cell.appendChild(badge);
19993 cell.style.maxHeight = 'none';
19994 });
19995 })();
19996 </script>
19997 <script nonce="{{ csp_nonce }}">
19998 (function(){
19999 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'}];
20000 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);});}
20001 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20002 function init(){
20003 var btn=document.getElementById('settings-btn');if(!btn)return;
20004 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20005 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>';
20006 document.body.appendChild(m);
20007 var g=document.getElementById('scheme-grid');
20008 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);});
20009 var cl=document.getElementById('settings-close');
20010 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);
20011 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');});
20012 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20013 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20014 }
20015 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20016 }());
20017 </script>
20018 <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>
20019</body>
20020</html>
20021"##,
20022 ext = "html"
20023)]
20024struct CompareSelectTemplate {
20025 version: &'static str,
20026 entries: Vec<HistoryEntryRow>,
20027 total_scans: usize,
20028 watched_dirs: Vec<String>,
20029 csp_nonce: String,
20030 server_mode: bool,
20031}
20032
20033#[derive(Template)]
20036#[template(
20037 source = r##"
20038<!doctype html>
20039<html lang="en">
20040<head>
20041 <meta charset="utf-8">
20042 <meta name="viewport" content="width=device-width, initial-scale=1">
20043 <title>OxideSLOC | Scan Delta</title>
20044 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20045 <style nonce="{{ csp_nonce }}">
20046 :root {
20047 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
20048 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
20049 --nav:#283790; --nav-2:#013e6b;
20050 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
20051 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
20052 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
20053 }
20054 body.dark-theme {
20055 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
20056 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
20057 }
20058 *{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;}
20059 .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);}
20060 .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;}
20061 .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));}
20062 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20063 .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;}
20064 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
20065 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20066 @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; } }
20067 .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;}
20068 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
20069 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20070 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20071 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20072 .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;}
20073 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20074 .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);}
20075 .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;}
20076 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20077 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20078 .settings-modal-body{padding:14px 16px 16px;}
20079 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20080 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20081 .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;}
20082 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20083 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20084 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20085 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20086 .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;}
20087 .tz-select:focus{border-color:var(--oxide);}
20088 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
20089 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
20090 .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;}
20091 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
20092 .hero-body{display:block;}
20093 .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;}
20094 .btn-back:hover{background:var(--line);}
20095 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
20096 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
20097 .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;}
20098 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
20099 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;}
20100 .muted{color:var(--muted);font-size:14px;}
20101 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
20102 .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;}
20103 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
20104 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
20105 .vpill-arrow{font-size:20px;color:var(--muted);}
20106 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
20107 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
20108 .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;}
20109 .delta-card.delta-card-wide{padding:22px 24px;}
20110 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
20111 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
20112 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
20113 .delta-card-from{font-size:15px;color:var(--muted);}
20114 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
20115 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
20116 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
20117 .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%;}
20118 .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;}
20119 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
20120 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
20121 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
20122 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
20123 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
20124 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
20125 .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;}
20126 .meta-card-commit:hover{color:var(--oxide);}
20127 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
20128 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
20129 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
20130 .meta-value{color:var(--text);font-size:13px;}
20131 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
20132 .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;}
20133 .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);}
20134 .delta-card:hover .dc-tip{display:block;}
20135 .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;}
20136 .export-btn:hover{background:var(--line);}
20137 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
20138 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
20139 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
20140 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
20141 .delta-card-change.zero{color:var(--muted);background:transparent;}
20142 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
20143 .delta-card-pct.pos{color:var(--pos);}
20144 .delta-card-pct.neg{color:var(--neg);}
20145 .delta-card-pct.zero{color:var(--muted);}
20146 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
20147 .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;}
20148 .insight-card.insight-flag{border-color:var(--oxide);}
20149 .insight-card:hover .dc-tip{display:block;}
20150 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
20151 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
20152 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
20153 .insight-label.flag{color:var(--oxide);}
20154 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
20155 .insight-val.pos{color:var(--pos);}
20156 .insight-val.neg{color:var(--neg);}
20157 .insight-val.high{color:#c0392a;}
20158 .insight-val.med{color:#926000;}
20159 .insight-val.low{color:var(--pos);}
20160 body.dark-theme .insight-val.high{color:#ff6b6b;}
20161 body.dark-theme .insight-val.med{color:#f0c060;}
20162 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
20163 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
20164 .fc-row{display:flex;align-items:center;gap:8px;}
20165 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
20166 .fc-label{color:var(--muted);}
20167 .fc-modified .fc-count{color:#926000;}
20168 .fc-added .fc-count{color:var(--pos);}
20169 .fc-removed .fc-count{color:var(--neg);}
20170 .fc-unchanged .fc-count{color:var(--muted);}
20171 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
20172 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
20173 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
20174 .chip.modified{background:#fff2d8;color:#926000;}
20175 .chip.added{background:#e8f5ed;color:#1a8f47;}
20176 .chip.removed{background:#fdeaea;color:#b33b3b;}
20177 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
20178 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
20179 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
20180 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
20181 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
20182 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
20183 .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;}
20184 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
20185 .tab-btn:hover:not(.active){background:var(--line);}
20186 .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;}
20187 .btn-reset:hover{background:var(--line);}
20188 .table-wrap{width:100%;overflow-x:auto;}
20189 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
20190 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;}
20191 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
20192 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
20193 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
20194 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
20195 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
20196 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20197 tr:last-child td{border-bottom:none;}
20198 tr.row-added td{background:rgba(26,143,71,0.06);}
20199 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
20200 tr.row-modified td{background:rgba(146,96,0,0.05);}
20201 tr.row-unchanged td{opacity:.6;}
20202 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
20203 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
20204 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
20205 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
20206 .status-badge.modified{background:#fff2d8;color:#926000;}
20207 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
20208 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
20209 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
20210 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
20211 .delta-val{font-weight:700;}
20212 .delta-val.pos{color:var(--pos);}
20213 .delta-val.neg{color:var(--neg);}
20214 .delta-val.zero{color:var(--muted);}
20215 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
20216 .from-to strong{color:var(--text);}
20217 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20218 .site-footer a{color:var(--muted);}
20219 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
20220 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
20221 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20222 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20223 .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;}
20224 .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;}
20225 .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;}
20226 @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));}}
20227 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
20228 .path-link:hover{color:var(--oxide-2);}
20229 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
20230 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
20231 a.vpill-id:hover{color:var(--oxide);}
20232 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
20233 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
20234 .pagination-info{font-size:13px;color:var(--muted);}
20235 .pagination-btns{display:flex;gap:6px;}
20236 .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;}
20237 .pg-btn:hover:not(:disabled){background:var(--line);}
20238 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20239 .pg-btn:disabled{opacity:.35;cursor:default;}
20240 .per-page-label{font-size:13px;color:var(--muted);}
20241 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;}
20242 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20243 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
20244 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
20245 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
20246 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
20247 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
20248 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
20249 .tab-btn.tab-unchanged{color:var(--muted);}
20250 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
20251 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
20252 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
20253 .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;}
20254 .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;}
20255 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
20256 .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;}
20257 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
20258 .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;}
20259 .submod-scope-btn:hover{background:var(--line);}
20260 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20261 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
20262 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
20263 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
20264 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
20265 body.dark-theme .ic-card{background:var(--surface-2);}
20266 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
20267 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
20268 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
20269 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
20270 #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;}
20271 </style>
20272</head>
20273<body>
20274 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20281 </div>
20282 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20283 <div class="top-nav">
20284 <div class="top-nav-inner">
20285 <a class="brand" href="/">
20286 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20287 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
20288 </a>
20289 <div class="nav-right">
20290 <a class="nav-pill" href="/">Home</a>
20291 <div class="nav-dropdown">
20292 <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>
20293 <div class="nav-dropdown-menu">
20294 <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>
20295 </div>
20296 </div>
20297 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20298 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20299 <div class="nav-dropdown">
20300 <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>
20301 <div class="nav-dropdown-menu">
20302 <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>
20303 </div>
20304 </div>
20305 <div class="server-status-wrap" id="server-status-wrap">
20306 <div class="nav-pill server-online-pill" id="server-status-pill">
20307 <span class="status-dot" id="status-dot"></span>
20308 <span id="server-status-label">Server</span>
20309 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20310 </div>
20311 <div class="server-status-tip">
20312 OxideSLOC is running — accessible on your network.
20313 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20314 </div>
20315 </div>
20316 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20317 <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>
20318 </button>
20319 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20320 <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>
20321 <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>
20322 </button>
20323 </div>
20324 </div>
20325 </div>
20326
20327 <div class="page">
20328 <section class="hero">
20329 <div class="hero-header">
20330 <div>
20331 <h1 class="delta-title">Scan Delta</h1>
20332 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
20333 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
20334 {% if let Some(sub) = active_submodule %}
20335 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
20336 {% else if super_scope_active %}
20337 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
20338 {% else %}
20339 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
20340 {% endif %}
20341 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
20342 </div>
20343 </div>
20344 <a class="btn-back" href="/compare-scans">
20345 <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>
20346 Compare Scans
20347 </a>
20348 </div>
20349 {% if has_any_submodule_data %}
20350 <div class="submod-scope-bar">
20351 <span class="submod-scope-label">
20352 <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>
20353 Scope:
20354 </span>
20355 <div class="submod-scope-divider"></div>
20356 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
20357 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
20358 title="All files — super-repo and all submodules combined">Full scan</a>
20359 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
20360 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
20361 title="Only files that are not part of any submodule">Super-repo only</a>
20362 {% for sub in submodule_options %}
20363 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
20364 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
20365 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
20366 {% endfor %}
20367 </div>
20368 {% endif %}
20369 <div class="hero-body">
20370 <div class="meta-strip">
20371 <div class="delta-card delta-card-meta">
20372 <div class="meta-card-header">
20373 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
20374 <div class="meta-card-project-col">
20375 <div class="meta-card-project">{{ project_name }}</div>
20376 {% if has_any_submodule_data %}
20377 {% if let Some(sub) = active_submodule %}
20378 <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>
20379 {% else if super_scope_active %}
20380 <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>
20381 {% else %}
20382 <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>
20383 {% endif %}
20384 {% endif %}
20385 </div>
20386 </div>
20387 {% if !baseline_git_commit.is_empty() %}
20388 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
20389 {% else %}
20390 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
20391 {% endif %}
20392 <div class="meta-card-rows">
20393 <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>
20394 <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>
20395 <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>
20396 <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>
20397 {% if let Some(tags) = baseline_git_tags %}
20398 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20399 {% endif %}
20400 </div>
20401 </div>
20402 <div class="delta-card delta-card-meta">
20403 <div class="meta-card-header">
20404 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
20405 <div class="meta-card-project-col">
20406 <div class="meta-card-project">{{ project_name }}</div>
20407 {% if has_any_submodule_data %}
20408 {% if let Some(sub) = active_submodule %}
20409 <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>
20410 {% else if super_scope_active %}
20411 <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>
20412 {% else %}
20413 <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>
20414 {% endif %}
20415 {% endif %}
20416 </div>
20417 </div>
20418 {% if !current_git_commit.is_empty() %}
20419 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
20420 {% else %}
20421 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
20422 {% endif %}
20423 <div class="meta-card-rows">
20424 <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>
20425 <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>
20426 <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>
20427 <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>
20428 {% if let Some(tags) = current_git_tags %}
20429 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20430 {% endif %}
20431 </div>
20432 </div>
20433 </div>
20434 <div class="delta-strip">
20435 <div class="delta-card">
20436 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
20437 <div class="delta-card-label">Code lines</div>
20438 <div class="delta-card-from">Before: {{ baseline_code }}</div>
20439 <div class="delta-card-to">{{ current_code }}</div>
20440 {% 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>
20441 {% 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>
20442 {% else %}<div class="delta-card-pct zero">±0%</div>
20443 {% endif %}
20444 </div>
20445 <div class="delta-card">
20446 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
20447 <div class="delta-card-label">Files analyzed</div>
20448 <div class="delta-card-from">Before: {{ baseline_files }}</div>
20449 <div class="delta-card-to">{{ current_files }}</div>
20450 {% 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>
20451 {% 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>
20452 {% else %}<div class="delta-card-pct zero">±0%</div>
20453 {% endif %}
20454 </div>
20455 <div class="delta-card">
20456 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
20457 <div class="delta-card-label">Comment lines</div>
20458 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
20459 <div class="delta-card-to">{{ current_comments }}</div>
20460 {% 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>
20461 {% 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>
20462 {% else %}<div class="delta-card-pct zero">±0%</div>
20463 {% endif %}
20464 </div>
20465 {{ coverage_delta_card|safe }}
20466 <div class="delta-card delta-card-wide">
20467 <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>
20468 <div class="delta-card-label">File changes</div>
20469 <div class="file-changes-grid">
20470 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
20471 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
20472 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
20473 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
20474 </div>
20475 </div>
20476 </div>
20477 <div class="insights-panel">
20478 <div class="insight-card">
20479 <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>
20480 <div class="insight-label">Lines Added</div>
20481 <div class="insight-val pos">+{{ code_lines_added }}</div>
20482 <div class="insight-sub">New or grown source lines</div>
20483 </div>
20484 <div class="insight-card">
20485 <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>
20486 <div class="insight-label">Lines Removed</div>
20487 <div class="insight-val neg">−{{ code_lines_removed }}</div>
20488 <div class="insight-sub">Deleted or shrunk source lines</div>
20489 </div>
20490 <div class="insight-card">
20491 <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>
20492 <div class="insight-label">Churn Rate</div>
20493 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
20494 <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>
20495 </div>
20496 {% if scope_flag %}
20497 <div class="insight-card insight-flag">
20498 <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>
20499 <div class="insight-label flag">Scope Signal</div>
20500 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
20501 <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>
20502 </div>
20503 {% endif %}
20504 </div>
20505 </div>
20506 </section>
20507
20508 <section class="panel" id="inline-charts-section">
20509 <h2>Scan Delta Charts</h2>
20510 <div class="ic-grid">
20511 <div class="ic-card">
20512 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
20513 <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>
20514 <div id="ic-c1"></div>
20515 </div>
20516 <div class="ic-card" id="ic-lang-card">
20517 <div class="ic-card-h2">Language Code Delta</div>
20518 <div id="ic-c3"></div>
20519 </div>
20520 <div class="ic-card">
20521 <div class="ic-card-h2">Delta by Metric</div>
20522 <div id="ic-c2"></div>
20523 </div>
20524 <div class="ic-card">
20525 <div class="ic-card-h2">File Change Distribution</div>
20526 <div id="ic-c4"></div>
20527 </div>
20528 </div>
20529 </section>
20530
20531 <section class="panel">
20532 <h2>File-level delta</h2>
20533 <div class="filter-tabs-row">
20534 <div class="filter-tabs">
20535 <button class="tab-btn tab-all active" data-filter="all">All</button>
20536 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
20537 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
20538 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
20539 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
20540 </div>
20541 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
20542 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
20543 <div class="export-group">
20544 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
20545 <button type="button" class="export-btn" id="delta-csv-btn">
20546 <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>
20547 CSV
20548 </button>
20549 <button type="button" class="export-btn" id="delta-xls-btn">
20550 <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>
20551 Excel
20552 </button>
20553 <button type="button" class="export-btn" id="delta-charts-btn">
20554 <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>
20555 Charts
20556 </button>
20557 </div>
20558 </div>
20559 </div>
20560
20561 <div class="table-wrap">
20562 <table id="delta-table">
20563 <colgroup>
20564 <col>
20565 <col>
20566 <col>
20567 <col>
20568 <col>
20569 <col>
20570 <col>
20571 </colgroup>
20572 <thead>
20573 <tr id="delta-thead">
20574 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
20575 <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>
20576 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
20577 <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>
20578 <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>
20579 <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>
20580 <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>
20581 </tr>
20582 </thead>
20583 <tbody id="delta-tbody">
20584 {% for row in file_rows %}
20585 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
20586 data-path="{{ row.relative_path }}"
20587 data-language="{{ row.language }}"
20588 data-baseline-code="{{ row.baseline_code }}"
20589 data-current-code="{{ row.current_code }}"
20590 data-code-delta="{{ row.code_delta_str }}"
20591 data-comment-delta="{{ row.comment_delta_str }}"
20592 data-total-delta="{{ row.total_delta_str }}"
20593 data-orig-idx="">
20594 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
20595 <td class="hide-sm">{{ row.language }}</td>
20596 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
20597 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
20598 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
20599 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
20600 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
20601 </tr>
20602 {% endfor %}
20603 </tbody>
20604 </table>
20605 </div>
20606 <div class="pagination">
20607 <span class="pagination-info" id="pg-info"></span>
20608 <div class="pagination-btns" id="pg-btns"></div>
20609 <div class="flex-row">
20610 <span class="per-page-label">Show</span>
20611 <select class="per-page" id="per-page-sel">
20612 <option value="10">10 per page</option>
20613 <option value="25" selected>25 per page</option>
20614 <option value="50">50 per page</option>
20615 <option value="100">100 per page</option>
20616 </select>
20617 <span class="per-page-label" id="pg-range-label"></span>
20618 </div>
20619 </div>
20620 </section>
20621 </div>
20622
20623 <div id="ic-tt"></div>
20624
20625 <footer class="site-footer">
20626 local code analysis - metrics, history and reports
20627 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
20628 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20629 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20630 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20631 · <a href="/api-docs" rel="noopener">REST API</a>
20632 </footer>
20633
20634 <script nonce="{{ csp_nonce }}">
20635 (function () {
20636 var storageKey = 'oxide-sloc-theme';
20637 var body = document.body;
20638 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20639 var toggle = document.getElementById('theme-toggle');
20640 if (toggle) toggle.addEventListener('click', function () {
20641 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20642 body.classList.toggle('dark-theme', next === 'dark');
20643 try { localStorage.setItem(storageKey, next); } catch(e) {}
20644 });
20645
20646 (function randomizeWatermarks() {
20647 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20648 if (!wms.length) return;
20649 var placed = [];
20650 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;}
20651 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];}
20652 var half=Math.floor(wms.length/2);
20653 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;});
20654 })();
20655
20656 (function spawnCodeParticles() {
20657 var container = document.getElementById('code-particles');
20658 if (!container) return;
20659 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'];
20660 for (var i = 0; i < 38; i++) {
20661 (function(idx) {
20662 var el = document.createElement('span');
20663 el.className = 'code-particle';
20664 el.textContent = snippets[idx % snippets.length];
20665 var left = Math.random() * 94 + 2;
20666 var top = Math.random() * 88 + 6;
20667 var dur = (Math.random() * 10 + 9).toFixed(1);
20668 var delay = (Math.random() * 18).toFixed(1);
20669 var rot = (Math.random() * 26 - 13).toFixed(1);
20670 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20671 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';
20672 container.appendChild(el);
20673 })(i);
20674 }
20675 })();
20676 })();
20677
20678 var activeStatusFilter = 'all';
20679 var deltaPerPage = 25, deltaCurrPage = 1;
20680
20681 function openFolder(path) {
20682 fetch('/open-path?path=' + encodeURIComponent(path))
20683 .then(function (r) { return r.json(); })
20684 .then(function (d) {
20685 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20686 })
20687 .catch(function () {});
20688 }
20689
20690 function getDeltaFilteredRows() {
20691 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20692 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20693 });
20694 }
20695
20696 function renderDeltaPage() {
20697 var filtered = getDeltaFilteredRows();
20698 var total = filtered.length;
20699 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20700 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20701 var start = (deltaCurrPage - 1) * deltaPerPage;
20702 var end = Math.min(start + deltaPerPage, total);
20703 var shownSet = {};
20704 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20705 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20706 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20707 });
20708 var rl = document.getElementById('pg-range-label');
20709 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20710 var info = document.getElementById('pg-info');
20711 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20712 var btns = document.getElementById('pg-btns');
20713 if (!btns) return;
20714 btns.innerHTML = '';
20715 if (totalPages <= 1) return;
20716 function makeBtn(lbl, pg, active, disabled) {
20717 var b = document.createElement('button');
20718 b.className = 'pg-btn' + (active ? ' active' : '');
20719 b.textContent = lbl; b.disabled = disabled;
20720 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20721 return b;
20722 }
20723 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20724 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20725 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20726 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20727 }
20728
20729 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20730
20731 function filterRows(status, btn) {
20732 activeStatusFilter = status;
20733 deltaCurrPage = 1;
20734 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20735 b.classList.remove('active');
20736 });
20737 if (btn) btn.classList.add('active');
20738 renderDeltaPage();
20739 }
20740
20741 // ── Sorting ──────────────────────────────────────────────────────────────
20742 var sortCol = null, sortOrder = 'asc';
20743 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20744 (function() {
20745 var tbody = document.getElementById('delta-tbody');
20746 if (!tbody) return;
20747 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20748 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20749 })();
20750
20751 function parseDeltaNum(str) {
20752 if (!str || str === '—') return 0;
20753 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20754 }
20755
20756 sortHeaders.forEach(function(th) {
20757 th.addEventListener('click', function(e) {
20758 if (e.target.classList.contains('col-resize-handle')) return;
20759 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20760 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20761 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20762 th.classList.add('sort-' + sortOrder);
20763 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20764 var tbody = document.getElementById('delta-tbody');
20765 if (!tbody) return;
20766 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20767 rows.sort(function(a, b) {
20768 var va, vb;
20769 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20770 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20771 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20772 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20773 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20774 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20775 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20776 else { va = ''; vb = ''; }
20777 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20778 return va < vb ? 1 : va > vb ? -1 : 0;
20779 });
20780 rows.forEach(function(r) { tbody.appendChild(r); });
20781 deltaCurrPage = 1;
20782 renderDeltaPage();
20783 var activeBtn = document.querySelector('.tab-btn.active');
20784 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20785 if (activeBtn) activeBtn.classList.add('active');
20786 });
20787 });
20788
20789 // ── Column resize ─────────────────────────────────────────────────────────
20790 (function() {
20791 var table = document.getElementById('delta-table');
20792 if (!table) return;
20793 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20794 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20795 ths.forEach(function(th, i) {
20796 var handle = th.querySelector('.col-resize-handle');
20797 if (!handle || !cols[i]) return;
20798 var startX, startW;
20799 handle.addEventListener('mousedown', function(e) {
20800 e.stopPropagation(); e.preventDefault();
20801 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20802 handle.classList.add('dragging');
20803 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20804 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20805 document.addEventListener('mousemove', onMove);
20806 document.addEventListener('mouseup', onUp);
20807 });
20808 });
20809 })();
20810
20811 // ── Reset ─────────────────────────────────────────────────────────────────
20812 window.resetDeltaTable = function() {
20813 sortCol = null; sortOrder = 'asc';
20814 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20815 var tbody = document.getElementById('delta-tbody');
20816 if (tbody) {
20817 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20818 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20819 rows.forEach(function(r) { tbody.appendChild(r); });
20820 }
20821 var table = document.getElementById('delta-table');
20822 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20823 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20824 activeStatusFilter = 'all';
20825 deltaCurrPage = 1;
20826 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20827 var allBtn = document.querySelector('.tab-btn');
20828 if (allBtn) allBtn.classList.add('active');
20829 renderDeltaPage();
20830 };
20831
20832 renderDeltaPage();
20833
20834 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20835 (function() {
20836 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20837 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20838 });
20839 var resetBtn = document.getElementById('delta-reset-btn');
20840 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20841 var csvBtn = document.getElementById('delta-csv-btn');
20842 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20843 var xlsBtn = document.getElementById('delta-xls-btn');
20844 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20845 var chartsBtn = document.getElementById('delta-charts-btn');
20846 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20847 var ppSel = document.getElementById('per-page-sel');
20848 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20849 var pathLink = document.getElementById('project-path-link');
20850 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20851 })();
20852
20853 // ── Export helpers ────────────────────────────────────────────────────────
20854 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
20855 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20856 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);}
20857 function slocMakeXlsx(fname,sd,dr){
20858 var enc=new TextEncoder();
20859 // CRC-32 table
20860 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;}
20861 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;}
20862 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20863 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20864 // Shared string table
20865 var ss=[],si={};
20866 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20867 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20868 // Worksheet builder — each WS() call gets its own row counter R
20869 function WS(){
20870 var R=0,buf=[];
20871 function cl(c){return String.fromCharCode(65+c);}
20872 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20873 '<v>'+S(v)+'</v></c>';}
20874 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20875 (st?' s="'+st+'"':'')+'>'+
20876 '<v>'+(+v)+'</v></c>';}
20877 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20878 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20879 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20880 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20881 '<sheetFormatPr defaultRowHeight="15"/>'+
20882 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20883 return{sc:sc,nc:nc,row:row,xml:xml};
20884 }
20885 // Language breakdown
20886 var lm={};
20887 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;});
20888 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20889 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20890 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20891 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20892 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20893 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20894 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):'';}
20895 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20896 // Summary sheet
20897 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20898 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20899 r1(s1(0,proj,2));
20900 r1(s1(0,sd.bts+' → '+sd.cts,2));
20901 r1('');
20902 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20903 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))));
20904 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))));
20905 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))));
20906 r1('');
20907 r1(s1(0,'FILE CHANGES',8));
20908 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20909 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20910 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20911 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20912 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20913 if(langs.length){
20914 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20915 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20916 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)));});
20917 }
20918 r1('');r1(s1(0,'SCAN METADATA',8));
20919 r1(s1(1,_blabel)+s1(2,_clabel));
20920 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20921 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20922 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"/>');
20923 // File Delta sheet
20924 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20925 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));
20926 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)));});
20927 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20928 // Shared strings XML
20929 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20930 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20931 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20932 // XLSX file map
20933 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20934 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>',
20935 '_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>',
20936 '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>',
20937 '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>',
20938 '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>',
20939 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20940 // ZIP packer — STORED (no compression), compatible with all XLSX readers
20941 var zparts=[],zcds=[],zoff=0,znf=0;
20942 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20943 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20944 ].forEach(function(name){
20945 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20946 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]);
20947 var entry=new Uint8Array(lha.length+nb.length+sz);
20948 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20949 zparts.push(entry);
20950 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));
20951 var cde=new Uint8Array(cda.length+nb.length);
20952 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20953 zcds.push(cde);zoff+=entry.length;znf++;
20954 });
20955 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20956 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]);
20957 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20958 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20959 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20960 zout.set(new Uint8Array(ea),zpos);
20961 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20962 var xurl=URL.createObjectURL(xblob);
20963 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20964 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20965 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20966 }
20967 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;');}
20968 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20969 function getExportFilename(ext){return _exportBase+'.'+ext;}
20970
20971 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 }}'};
20972 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;}
20973 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20974 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20975 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20976 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20977 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):'';}
20978 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20979 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)]];}
20980 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20981 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;}
20982 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20983 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20984
20985 // ── Chart HTML report ─────────────────────────────────────────────────────
20986 function slocChartReport(fname, sd, dr) {
20987 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20988 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20989 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20990 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();}
20991 function px(n){return Math.round(n);}
20992 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20993 // Language map
20994 var lm={};
20995 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;});
20996 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20997
20998 // Builds onmouse* attrs for interactive tooltip on each SVG element
20999 function barTT(label,val){
21000 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
21001 }
21002
21003 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
21004 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'}];
21005 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21006 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
21007 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
21008 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21009 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"/>';}
21010 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21011 c1mets.forEach(function(m,i){
21012 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21013 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21014 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>';
21015 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))+'/>';
21016 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>';
21017 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))+'/>';
21018 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>';
21019 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>';
21020 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>';
21021 });
21022 c1+='</svg>';
21023
21024 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
21025 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'}];
21026 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21027 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
21028 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
21029 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21030 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21031 mets.forEach(function(m,i){
21032 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
21033 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
21034 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
21035 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>';
21036 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
21037 if(bw>=52){
21038 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>';
21039 }else{
21040 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
21041 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>';
21042 }
21043 });
21044 c2+='</svg>';
21045
21046 // ── Chart 3: Language Code Delta ─────────────────────────────────────
21047 var c3='';
21048 if(langs.length){
21049 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21050 var C3W=550,c3LW=124,c3FW=52;
21051 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
21052 var L3rH=30,C3H=langs.length*L3rH+20;
21053 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21054 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21055 langs.forEach(function(l,i){
21056 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
21057 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
21058 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
21059 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21060 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':''))+'/>';
21061 if(bw>=48){
21062 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>';
21063 }else{
21064 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
21065 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>';
21066 }
21067 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>';
21068 });
21069 c3+='</svg>';
21070 }
21071
21072 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
21073 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;});
21074 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21075 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
21076 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21077 var ang=-Math.PI/2;
21078 segs.forEach(function(s){
21079 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21080 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
21081 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
21082 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
21083 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
21084 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)+'%')+'/>';
21085 ang+=sw;
21086 });
21087 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>';
21088 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21089 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>';});
21090 c4+='</svg>';
21091
21092 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
21093 var ttJs='var tt=document.getElementById("ox-tt");'+
21094 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
21095 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
21096 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
21097 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
21098 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
21099 'function oxHT(){tt.style.display="none";}';
21100
21101 // body max-width keeps charts from inflating beyond design dimensions on
21102 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
21103 // each chart's height blows up proportionally, breaking the one-page layout.
21104 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;}'+
21105 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
21106 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
21107 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
21108 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
21109 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
21110 'svg{display:block;}'+
21111 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
21112 '#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;}'+
21113 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
21114 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
21115 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
21116 '<div id="ox-tt"><\/div>'+
21117 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
21118 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
21119 '<div class="two-col">'+
21120 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
21121 '<div class="leg">'+
21122 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
21123 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
21124 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
21125 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
21126 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
21127 '<\/div>'+
21128 '<div class="two-col">'+
21129 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
21130 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
21131 '<\/div>'+
21132 '<script>'+ttJs+'<\/script>'+
21133 '<\/body><\/html>';
21134 slocDownload(html, fname, 'text/html;charset=utf-8;');
21135 }
21136 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
21137 // ── Inline delta charts ────────────────────────────────────────────────────
21138 var _icTT=document.getElementById('ic-tt');
21139 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
21140 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';};
21141 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
21142 (function(){
21143 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
21144 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21145 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();}
21146 function px(n){return Math.round(n);}
21147 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
21148 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
21149 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);});}
21150 var dr=getDeltaExportRows(),sd=_sd,lm={};
21151 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;});
21152 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
21153 // Chart 1: Baseline vs Current grouped bars
21154 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'}];
21155 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21156 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;
21157 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21158 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"/>';}
21159 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21160 c1mets.forEach(function(m,i){
21161 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21162 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21163 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>';
21164 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"/>';
21165 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>';
21166 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"/>';
21167 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>';
21168 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>';
21169 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>';
21170 });
21171 c1+='</svg>';
21172 // Chart 2: Delta by Metric
21173 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'}];
21174 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21175 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;
21176 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21177 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21178 mets.forEach(function(m,i){
21179 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);
21180 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>';
21181 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
21182 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>';}
21183 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>';}
21184 });
21185 c2+='</svg>';
21186 // Chart 3: Language Code Delta
21187 var c3='';
21188 if(langs.length){
21189 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21190 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;
21191 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21192 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21193 langs.forEach(function(l,i){
21194 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);
21195 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21196 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"/>';
21197 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>';}
21198 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>';}
21199 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>';
21200 });
21201 c3+='</svg>';
21202 }
21203 // Chart 4: File Change Donut
21204 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;});
21205 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21206 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;
21207 if(segs.length===1){
21208 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
21209 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
21210 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
21211 } else {
21212 segs.forEach(function(s){
21213 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21214 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);
21215 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);
21216 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"/>';
21217 ang+=sw;
21218 });
21219 }
21220 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>';
21221 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21222 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>';});
21223 c4+='</svg>';
21224 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
21225 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
21226 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);}
21227 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
21228 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
21229 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent=' /'+el.textContent.replace(/\s+/g,'');});
21230 })();
21231 </script>
21232 <script nonce="{{ csp_nonce }}">
21233 (function(){
21234 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'}];
21235 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);});}
21236 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21237 function init(){
21238 var btn=document.getElementById('settings-btn');if(!btn)return;
21239 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21240 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>';
21241 document.body.appendChild(m);
21242 var g=document.getElementById('scheme-grid');
21243 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);});
21244 var cl=document.getElementById('settings-close');
21245 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);
21246 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');});
21247 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21248 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21249 }
21250 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21251 }());
21252 </script>
21253 <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>
21254</body>
21255</html>
21256"##,
21257 ext = "html"
21258)]
21259#[allow(clippy::struct_excessive_bools)]
21261struct CompareTemplate {
21262 version: &'static str,
21263 project_label: String,
21264 baseline_git_commit: String,
21265 current_git_commit: String,
21266 baseline_run_id: String,
21267 current_run_id: String,
21268 baseline_run_id_short: String,
21269 current_run_id_short: String,
21270 baseline_timestamp: String,
21271 baseline_timestamp_utc_ms: i64,
21272 current_timestamp: String,
21273 current_timestamp_utc_ms: i64,
21274 project_path: String,
21275 baseline_code: u64,
21276 current_code: u64,
21277 code_lines_delta_str: String,
21278 code_lines_delta_class: String,
21279 baseline_files: u64,
21280 current_files: u64,
21281 files_analyzed_delta_str: String,
21282 files_analyzed_delta_class: String,
21283 baseline_comments: u64,
21284 current_comments: u64,
21285 comment_lines_delta_str: String,
21286 comment_lines_delta_class: String,
21287 code_lines_pct_str: String,
21288 files_analyzed_pct_str: String,
21289 comment_lines_pct_str: String,
21290 code_lines_added: i64,
21291 code_lines_removed: i64,
21292 new_scope: bool,
21294 churn_rate_str: String,
21295 churn_rate_class: String,
21296 scope_flag: bool,
21297 files_added: usize,
21298 files_removed: usize,
21299 files_modified: usize,
21300 files_unchanged: usize,
21301 file_rows: Vec<CompareFileDeltaRow>,
21302 baseline_git_author: Option<String>,
21303 current_git_author: Option<String>,
21304 baseline_git_branch: String,
21305 current_git_branch: String,
21306 baseline_git_tags: Option<String>,
21307 current_git_tags: Option<String>,
21308 baseline_git_commit_date: Option<String>,
21309 current_git_commit_date: Option<String>,
21310 project_name: String,
21311 submodule_options: Vec<String>,
21313 has_any_submodule_data: bool,
21315 active_submodule: Option<String>,
21317 super_scope_active: bool,
21319 csp_nonce: String,
21320 coverage_delta_card: String,
21322}
21323
21324#[derive(Template)]
21327#[template(
21328 source = r##"
21329<!doctype html>
21330<html lang="en">
21331<head>
21332 <meta charset="utf-8">
21333 <meta name="viewport" content="width=device-width, initial-scale=1">
21334 <title>OxideSLOC | Sign In</title>
21335 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21336 <style nonce="{{ csp_nonce }}">
21337 :root {
21338 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
21339 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
21340 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
21341 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
21342 }
21343 *{box-sizing:border-box;}
21344 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);}
21345 .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);}
21346 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
21347 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
21348 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
21349 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21350 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21351 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21352 .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;}
21353 @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));}}
21354 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
21355 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
21356 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21357 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
21358 .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;}
21359 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
21360 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;}
21361 input[type=password]:focus{border-color:var(--oxide);}
21362 .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;}
21363 .btn:hover{opacity:.88;}
21364 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
21365 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
21366 </style>
21367</head>
21368<body>
21369 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21377 </div>
21378 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21379<nav class="top-nav">
21380 <a class="brand" href="/">
21381 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21382 <span class="brand-title">OxideSLOC</span>
21383 </a>
21384</nav>
21385<main class="page">
21386 <div class="card">
21387 <h1>Sign In</h1>
21388 <p class="subtitle">Enter the API key printed when the server started.</p>
21389 {% if has_error %}
21390 <div class="error">Incorrect API key — please try again.</div>
21391 {% endif %}
21392 <form method="POST" action="/auth/login">
21393 <input type="hidden" name="next" value="{{ next_url|e }}">
21394 <label for="key">API Key</label>
21395 <input id="key" type="password" name="key" autocomplete="current-password"
21396 placeholder="Paste your API key here" autofocus>
21397 <button type="submit" class="btn">Sign In</button>
21398 </form>
21399 <p class="hint">
21400 The API key was printed in the terminal when the server started.<br>
21401 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
21402 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
21403 </p>
21404 </div>
21405</main>
21406<script nonce="{{ csp_nonce }}">
21407(function() {
21408 (function randomizeWatermarks() {
21409 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21410 if (!wms.length) return;
21411 var placed = [];
21412 function tooClose(top, left) {
21413 for (var i = 0; i < placed.length; i++) {
21414 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21415 if (dt < 16 && dl < 12) return true;
21416 }
21417 return false;
21418 }
21419 function pick(leftBand) {
21420 for (var attempt = 0; attempt < 50; attempt++) {
21421 var top = Math.random() * 88 + 2;
21422 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21423 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21424 }
21425 var top = Math.random() * 88 + 2;
21426 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21427 placed.push([top, left]); return [top, left];
21428 }
21429 var half = Math.floor(wms.length / 2);
21430 wms.forEach(function (img, i) {
21431 var pos = pick(i < half);
21432 var size = Math.floor(Math.random() * 100 + 120);
21433 var rot = (Math.random() * 360).toFixed(1);
21434 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21435 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;
21436 });
21437 })();
21438 (function spawnCodeParticles() {
21439 var container = document.getElementById('code-particles');
21440 if (!container) return;
21441 var snippets = [
21442 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21443 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21444 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21445 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21446 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21447 ];
21448 var count = 38;
21449 for (var i = 0; i < count; i++) {
21450 (function(idx) {
21451 var el = document.createElement('span');
21452 el.className = 'code-particle';
21453 el.textContent = snippets[idx % snippets.length];
21454 var left = Math.random() * 94 + 2;
21455 var top = Math.random() * 88 + 6;
21456 var dur = (Math.random() * 10 + 9).toFixed(1);
21457 var delay = (Math.random() * 18).toFixed(1);
21458 var rot = (Math.random() * 26 - 13).toFixed(1);
21459 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21460 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
21461 container.appendChild(el);
21462 })(i);
21463 }
21464 })();
21465})();
21466</script>
21467</body>
21468</html>
21469"##,
21470 ext = "html"
21471)]
21472pub(crate) struct LoginTemplate {
21473 pub(crate) csp_nonce: String,
21474 pub(crate) has_error: bool,
21475 pub(crate) next_url: String,
21476 pub(crate) lockout_threshold: u32,
21477}
21478
21479#[derive(Template)]
21482#[template(
21483 source = r##"
21484<!doctype html>
21485<html lang="en">
21486<head>
21487 <meta charset="utf-8">
21488 <meta name="viewport" content="width=device-width, initial-scale=1">
21489 <title>OxideSLOC — REST API Reference</title>
21490 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21491 <style nonce="{{ csp_nonce }}">
21492 :root {
21493 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21494 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21495 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21496 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21497 --success:#16a34a;
21498 }
21499 body.dark-theme {
21500 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21501 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21502 }
21503 *{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;}
21504 .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);}
21505 .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;}
21506 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
21507 .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));}
21508 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21509 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21510 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
21511 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
21512 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21513 @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; } }
21514 .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;}
21515 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
21516 .nav-pill.active{background:rgba(255,255,255,0.22);}
21517 .nav-dropdown{position:relative;display:inline-flex;}
21518 .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;}
21519 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
21520 .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;}
21521 .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;}
21522 .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);}
21523 .nav-dropdown-menu a:last-child{border-bottom:none;}
21524 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
21525 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21526 .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;}
21527 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21528 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21529 .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;}
21530 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21531 .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);}
21532 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
21533 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
21534 .settings-modal-body{padding:14px 16px 16px;}
21535 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21536 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21537 .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;}
21538 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21539 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21540 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21541 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21542 .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;}
21543 .tz-select:focus{border-color:var(--oxide);}
21544 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21545 .page-header{margin-bottom:28px;}
21546 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
21547 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
21548 .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;}
21549 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
21550 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
21551 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
21552 .callout strong{font-weight:800;}
21553 .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;}
21554 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
21555 .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;}
21556 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
21557 .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;}
21558 body.dark-theme .base-url-value{color:var(--accent);}
21559 .section{margin-bottom:36px;}
21560 .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);}
21561 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
21562 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
21563 .ep-header:hover{background:var(--surface-2);}
21564 .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;}
21565 .method.get{background:#dcfce7;color:#166534;}
21566 .method.post{background:#dbeafe;color:#1e40af;}
21567 .method.delete{background:#fee2e2;color:#991b1b;}
21568 body.dark-theme .method.get{background:#14532d;color:#86efac;}
21569 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
21570 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
21571 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
21572 .ep-path .param{color:var(--oxide-2);}
21573 body.dark-theme .ep-path .param{color:var(--oxide);}
21574 .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;}
21575 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
21576 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
21577 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
21578 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
21579 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
21580 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
21581 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
21582 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
21583 .ep-card.open .chevron{transform:rotate(180deg);}
21584 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
21585 .ep-card.open .ep-body{display:block;}
21586 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
21587 .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;}
21588 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
21589 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
21590 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21591 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
21592 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);}
21593 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
21594 table.params tr:last-child td{border-bottom:none;}
21595 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
21596 .pt-type{color:var(--muted-2);font-size:12px;}
21597 .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;}
21598 .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;}
21599 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
21600 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
21601 details.schema{margin-bottom:14px;}
21602 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;}
21603 details.schema summary:hover{color:var(--text);}
21604 .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;}
21605 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21606 .curl-wrap{position:relative;}
21607 .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;}
21608 .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;}
21609 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
21610 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
21611 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
21612 .webhook-note a{color:var(--accent-2);text-decoration:none;}
21613 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21614 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21615 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21616 .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;}
21617 @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));}}
21618 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21619 .site-footer a{color:var(--muted);}
21620 </style>
21621</head>
21622<body>
21623 <div class="background-watermarks" aria-hidden="true">
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 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21631 </div>
21632 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21633 <div class="top-nav">
21634 <div class="top-nav-inner">
21635 <a class="brand" href="/">
21636 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21637 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21638 </a>
21639 <div class="nav-right">
21640 <a class="nav-pill" href="/">Home</a>
21641 <div class="nav-dropdown">
21642 <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>
21643 <div class="nav-dropdown-menu">
21644 <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>
21645 </div>
21646 </div>
21647 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21648 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21649 <div class="nav-dropdown">
21650 <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>
21651 <div class="nav-dropdown-menu">
21652 <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>
21653 </div>
21654 </div>
21655 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21656 <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>
21657 </button>
21658 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21659 <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>
21660 <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>
21661 </button>
21662 </div>
21663 </div>
21664 </div>
21665
21666 <div class="page">
21667 <div class="page-header">
21668 <h1 class="page-title">REST API Reference</h1>
21669 <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>
21670 </div>
21671
21672 {% if has_api_key %}
21673 <div class="callout key-set">
21674 <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>
21675 <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>
21676 </div>
21677 {% else %}
21678 <div class="callout no-key">
21679 <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>
21680 <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>
21681 </div>
21682 {% endif %}
21683
21684 <div class="base-url-bar">
21685 <span class="base-url-label">Base URL</span>
21686 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21687 </div>
21688
21689 <!-- Health -->
21690 <div class="section">
21691 <h2 class="section-title">Health & Status</h2>
21692 <div class="ep-card">
21693 <div class="ep-header">
21694 <span class="method get">GET</span>
21695 <span class="ep-path">/healthz</span>
21696 <span class="auth-badge public">Public</span>
21697 <span class="ep-desc">Server liveness check</span>
21698 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21699 </div>
21700 <div class="ep-body">
21701 <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>
21702 <p class="params-heading">Response</p>
21703 <div class="schema-block">200 OK
21704Content-Type: text/plain
21705
21706ok</div>
21707 <p class="curl-heading">Example</p>
21708 <div class="curl-wrap">
21709 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21710 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21711 </div>
21712 </div>
21713 </div>
21714 </div>
21715
21716 <!-- Badges -->
21717 <div class="section">
21718 <h2 class="section-title">Badges</h2>
21719 <div class="ep-card">
21720 <div class="ep-header">
21721 <span class="method get">GET</span>
21722 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21723 <span class="auth-badge public">Public</span>
21724 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21725 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21726 </div>
21727 <div class="ep-body">
21728 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21729 <p class="params-heading">Path Parameters</p>
21730 <table class="params">
21731 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21732 <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>
21733 </table>
21734 <p class="curl-heading">Example</p>
21735 <div class="curl-wrap">
21736 <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>
21737 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21738 </div>
21739 </div>
21740 </div>
21741 </div>
21742
21743 <!-- Metrics -->
21744 <div class="section">
21745 <h2 class="section-title">Metrics</h2>
21746
21747 <div class="ep-card">
21748 <div class="ep-header">
21749 <span class="method get">GET</span>
21750 <span class="ep-path">/api/metrics/latest</span>
21751 <span class="auth-badge protected">Protected</span>
21752 <span class="ep-desc">Latest scan metrics (JSON)</span>
21753 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21754 </div>
21755 <div class="ep-body">
21756 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21757 <details class="schema"><summary>Response schema</summary>
21758<div class="schema-block">{
21759 "run_id": string, // UUID
21760 "timestamp": string, // ISO-8601 UTC
21761 "project": string, // scanned root path
21762 "summary": {
21763 "files_analyzed": number,
21764 "files_skipped": number,
21765 "code_lines": number,
21766 "comment_lines": number,
21767 "blank_lines": number,
21768 "total_physical_lines": number,
21769 "functions": number,
21770 "classes": number,
21771 "variables": number,
21772 "imports": number
21773 },
21774 "languages": [
21775 { "name": string, "files": number, "code_lines": number,
21776 "comment_lines": number, "blank_lines": number,
21777 "functions": number, "classes": number,
21778 "variables": number, "imports": number }
21779 ]
21780}</div></details>
21781 <p class="curl-heading">Example</p>
21782 <div class="curl-wrap">
21783 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21784 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21785 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21786 </div>
21787 </div>
21788 </div>
21789
21790 <div class="ep-card">
21791 <div class="ep-header">
21792 <span class="method get">GET</span>
21793 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21794 <span class="auth-badge protected">Protected</span>
21795 <span class="ep-desc">Metrics for a specific run</span>
21796 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21797 </div>
21798 <div class="ep-body">
21799 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21800 <p class="params-heading">Path Parameters</p>
21801 <table class="params">
21802 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21803 <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>
21804 </table>
21805 <p class="curl-heading">Example</p>
21806 <div class="curl-wrap">
21807 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21808 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
21809 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21810 </div>
21811 </div>
21812 </div>
21813
21814 <div class="ep-card">
21815 <div class="ep-header">
21816 <span class="method get">GET</span>
21817 <span class="ep-path">/api/metrics/history</span>
21818 <span class="auth-badge protected">Protected</span>
21819 <span class="ep-desc">Paginated scan history</span>
21820 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21821 </div>
21822 <div class="ep-body">
21823 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21824 <p class="params-heading">Query Parameters</p>
21825 <table class="params">
21826 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21827 <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>
21828 <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>
21829 </table>
21830 <details class="schema"><summary>Response schema</summary>
21831<div class="schema-block">[{
21832 "run_id": string,
21833 "timestamp": string, // ISO-8601 UTC
21834 "commit": string | null,
21835 "branch": string | null,
21836 "tags": string[],
21837 "code_lines": number,
21838 "comment_lines": number,
21839 "blank_lines": number,
21840 "physical_lines": number,
21841 "files_analyzed": number,
21842 "project_label": string,
21843 "html_url": string | null
21844}]</div></details>
21845 <p class="curl-heading">Example</p>
21846 <div class="curl-wrap">
21847 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21848 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21849 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21850 </div>
21851 </div>
21852 </div>
21853
21854 <div class="ep-card">
21855 <div class="ep-header">
21856 <span class="method get">GET</span>
21857 <span class="ep-path">/api/project-history</span>
21858 <span class="auth-badge protected">Protected</span>
21859 <span class="ep-desc">Project-level scan summary</span>
21860 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21861 </div>
21862 <div class="ep-body">
21863 <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>
21864 <p class="params-heading">Query Parameters</p>
21865 <table class="params">
21866 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21867 <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>
21868 </table>
21869 <details class="schema"><summary>Response schema</summary>
21870<div class="schema-block">{
21871 "scan_count": number,
21872 "last_scan_id": string | null,
21873 "last_scan_timestamp": string | null, // ISO-8601
21874 "last_scan_code_lines": number | null,
21875 "last_git_branch": string | null,
21876 "last_git_commit": string | null
21877}</div></details>
21878 <p class="curl-heading">Example</p>
21879 <div class="curl-wrap">
21880 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21881 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21882 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21883 </div>
21884 </div>
21885 </div>
21886
21887 <div class="ep-card">
21888 <div class="ep-header">
21889 <span class="method get">GET</span>
21890 <span class="ep-path">/api/metrics/submodules</span>
21891 <span class="auth-badge protected">Protected</span>
21892 <span class="ep-desc">List known git submodules across scans</span>
21893 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21894 </div>
21895 <div class="ep-body">
21896 <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>
21897 <p class="params-heading">Query Parameters</p>
21898 <table class="params">
21899 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21900 <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>
21901 </table>
21902 <details class="schema"><summary>Response schema</summary>
21903<div class="schema-block">[{
21904 "name": string, // submodule name
21905 "relative_path": string // path relative to the project root
21906}]</div></details>
21907 <p class="curl-heading">Example</p>
21908 <div class="curl-wrap">
21909 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21910 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21911 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21912 </div>
21913 </div>
21914 </div>
21915 </div>
21916
21917 <!-- Async Run Status -->
21918 <div class="section">
21919 <h2 class="section-title">Async Run Status</h2>
21920
21921 <div class="ep-card">
21922 <div class="ep-header">
21923 <span class="method get">GET</span>
21924 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21925 <span class="auth-badge protected">Protected</span>
21926 <span class="ep-desc">Poll scan completion</span>
21927 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21928 </div>
21929 <div class="ep-body">
21930 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21931 <details class="schema"><summary>Response schema</summary>
21932<div class="schema-block">// Running
21933{ "state": "running", "elapsed_secs": number }
21934
21935// Complete
21936{ "state": "complete", "run_id": string }
21937
21938// Failed
21939{ "state": "failed", "message": string }</div></details>
21940 <p class="curl-heading">Example</p>
21941 <div class="curl-wrap">
21942 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21943 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
21944 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21945 </div>
21946 </div>
21947 </div>
21948
21949 <div class="ep-card">
21950 <div class="ep-header">
21951 <span class="method get">GET</span>
21952 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21953 <span class="auth-badge protected">Protected</span>
21954 <span class="ep-desc">Poll PDF generation readiness</span>
21955 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21956 </div>
21957 <div class="ep-body">
21958 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21959 <details class="schema"><summary>Response schema</summary>
21960<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21961 <p class="curl-heading">Example</p>
21962 <div class="curl-wrap">
21963 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21964 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
21965 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21966 </div>
21967 </div>
21968 </div>
21969
21970 <div class="ep-card">
21971 <div class="ep-header">
21972 <span class="method post">POST</span>
21973 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21974 <span class="auth-badge protected">Protected</span>
21975 <span class="ep-desc">Cancel a running scan</span>
21976 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21977 </div>
21978 <div class="ep-body">
21979 <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>
21980 <p class="curl-heading">Example</p>
21981 <div class="curl-wrap">
21982 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21983 -H "Authorization: Bearer $SLOC_API_KEY" \
21984 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
21985 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21986 </div>
21987 </div>
21988 </div>
21989 </div>
21990
21991 <!-- Scan Profiles -->
21992 <div class="section">
21993 <h2 class="section-title">Scan Profiles</h2>
21994
21995 <div class="ep-card">
21996 <div class="ep-header">
21997 <span class="method get">GET</span>
21998 <span class="ep-path">/api/scan-profiles</span>
21999 <span class="auth-badge protected">Protected</span>
22000 <span class="ep-desc">List saved scan profiles</span>
22001 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22002 </div>
22003 <div class="ep-body">
22004 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
22005 <details class="schema"><summary>Response schema</summary>
22006<div class="schema-block">{
22007 "profiles": [{
22008 "id": string, // UUID
22009 "name": string,
22010 "created_at": string, // ISO-8601
22011 "params": object
22012 }]
22013}</div></details>
22014 <p class="curl-heading">Example</p>
22015 <div class="curl-wrap">
22016 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22017 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22018 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
22019 </div>
22020 </div>
22021 </div>
22022
22023 <div class="ep-card">
22024 <div class="ep-header">
22025 <span class="method post">POST</span>
22026 <span class="ep-path">/api/scan-profiles</span>
22027 <span class="auth-badge protected">Protected</span>
22028 <span class="ep-desc">Save a scan profile</span>
22029 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22030 </div>
22031 <div class="ep-body">
22032 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
22033 <p class="params-heading">Request Body (application/json)</p>
22034 <table class="params">
22035 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22036 <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>
22037 <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>
22038 </table>
22039 <details class="schema"><summary>Response schema</summary>
22040<div class="schema-block">{ "ok": true }</div></details>
22041 <p class="curl-heading">Example</p>
22042 <div class="curl-wrap">
22043 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
22044 -H "Authorization: Bearer $SLOC_API_KEY" \
22045 -H "Content-Type: application/json" \
22046 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
22047 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22048 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
22049 </div>
22050 </div>
22051 </div>
22052
22053 <div class="ep-card">
22054 <div class="ep-header">
22055 <span class="method delete">DELETE</span>
22056 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
22057 <span class="auth-badge protected">Protected</span>
22058 <span class="ep-desc">Delete a scan profile</span>
22059 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22060 </div>
22061 <div class="ep-body">
22062 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
22063 <p class="params-heading">Path Parameters</p>
22064 <table class="params">
22065 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22066 <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>
22067 </table>
22068 <details class="schema"><summary>Response schema</summary>
22069<div class="schema-block">{ "ok": true }</div></details>
22070 <p class="curl-heading">Example</p>
22071 <div class="curl-wrap">
22072 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
22073 -H "Authorization: Bearer $SLOC_API_KEY" \
22074 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
22075 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
22076 </div>
22077 </div>
22078 </div>
22079 </div>
22080
22081 <!-- Scheduled Scans -->
22082 <div class="section">
22083 <h2 class="section-title">Scheduled Scans</h2>
22084
22085 <div class="ep-card">
22086 <div class="ep-header">
22087 <span class="method get">GET</span>
22088 <span class="ep-path">/api/schedules</span>
22089 <span class="auth-badge protected">Protected</span>
22090 <span class="ep-desc">List configured schedules</span>
22091 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22092 </div>
22093 <div class="ep-body">
22094 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
22095 <p class="curl-heading">Example</p>
22096 <div class="curl-wrap">
22097 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22098 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22099 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
22100 </div>
22101 </div>
22102 </div>
22103
22104 <div class="ep-card">
22105 <div class="ep-header">
22106 <span class="method post">POST</span>
22107 <span class="ep-path">/api/schedules</span>
22108 <span class="auth-badge protected">Protected</span>
22109 <span class="ep-desc">Create a schedule</span>
22110 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22111 </div>
22112 <div class="ep-body">
22113 <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>
22114 <p class="curl-heading">Example</p>
22115 <div class="curl-wrap">
22116 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
22117 -H "Authorization: Bearer $SLOC_API_KEY" \
22118 -H "Content-Type: application/json" \
22119 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
22120 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22121 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
22122 </div>
22123 </div>
22124 </div>
22125
22126 <div class="ep-card">
22127 <div class="ep-header">
22128 <span class="method delete">DELETE</span>
22129 <span class="ep-path">/api/schedules</span>
22130 <span class="auth-badge protected">Protected</span>
22131 <span class="ep-desc">Delete a schedule</span>
22132 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22133 </div>
22134 <div class="ep-body">
22135 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
22136 <p class="curl-heading">Example</p>
22137 <div class="curl-wrap">
22138 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
22139 -H "Authorization: Bearer $SLOC_API_KEY" \
22140 -H "Content-Type: application/json" \
22141 -d '{"id":"<schedule_id>"}' \
22142 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22143 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
22144 </div>
22145 </div>
22146 </div>
22147 </div>
22148
22149 <!-- Git Browser -->
22150 <div class="section">
22151 <h2 class="section-title">Git Browser</h2>
22152
22153 <div class="ep-card">
22154 <div class="ep-header">
22155 <span class="method get">GET</span>
22156 <span class="ep-path">/api/git/refs</span>
22157 <span class="auth-badge protected">Protected</span>
22158 <span class="ep-desc">List git refs for a repository</span>
22159 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22160 </div>
22161 <div class="ep-body">
22162 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
22163 <p class="params-heading">Query Parameters</p>
22164 <table class="params">
22165 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22166 <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>
22167 </table>
22168 <p class="curl-heading">Example</p>
22169 <div class="curl-wrap">
22170 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22171 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
22172 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
22173 </div>
22174 </div>
22175 </div>
22176
22177 <div class="ep-card">
22178 <div class="ep-header">
22179 <span class="method get">GET</span>
22180 <span class="ep-path">/api/git/scan-ref</span>
22181 <span class="auth-badge protected">Protected</span>
22182 <span class="ep-desc">SLOC-scan a specific git ref</span>
22183 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22184 </div>
22185 <div class="ep-body">
22186 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
22187 <p class="params-heading">Query Parameters</p>
22188 <table class="params">
22189 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22190 <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>
22191 <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>
22192 </table>
22193 <p class="curl-heading">Example</p>
22194 <div class="curl-wrap">
22195 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22196 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
22197 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
22198 </div>
22199 </div>
22200 </div>
22201
22202 <div class="ep-card">
22203 <div class="ep-header">
22204 <span class="method get">GET</span>
22205 <span class="ep-path">/api/git/compare-refs</span>
22206 <span class="auth-badge protected">Protected</span>
22207 <span class="ep-desc">Compare SLOC across two git refs</span>
22208 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22209 </div>
22210 <div class="ep-body">
22211 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
22212 <p class="params-heading">Query Parameters</p>
22213 <table class="params">
22214 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22215 <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>
22216 <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>
22217 <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>
22218 </table>
22219 <p class="curl-heading">Example</p>
22220 <div class="curl-wrap">
22221 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22222 "<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>
22223 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
22224 </div>
22225 </div>
22226 </div>
22227 </div>
22228
22229 <!-- Webhooks -->
22230 <div class="section">
22231 <h2 class="section-title">Webhooks</h2>
22232 <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>
22233
22234 <div class="ep-card">
22235 <div class="ep-header">
22236 <span class="method post">POST</span>
22237 <span class="ep-path">/webhooks/github</span>
22238 <span class="auth-badge hmac">HMAC</span>
22239 <span class="ep-desc">GitHub push event receiver</span>
22240 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22241 </div>
22242 <div class="ep-body">
22243 <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>
22244 <p class="params-heading">Required Headers</p>
22245 <table class="params">
22246 <tr><th>Header</th><th>Value</th></tr>
22247 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
22248 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
22249 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22250 </table>
22251 </div>
22252 </div>
22253
22254 <div class="ep-card">
22255 <div class="ep-header">
22256 <span class="method post">POST</span>
22257 <span class="ep-path">/webhooks/gitlab</span>
22258 <span class="auth-badge hmac">HMAC</span>
22259 <span class="ep-desc">GitLab push event receiver</span>
22260 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22261 </div>
22262 <div class="ep-body">
22263 <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>
22264 <p class="params-heading">Required Headers</p>
22265 <table class="params">
22266 <tr><th>Header</th><th>Value</th></tr>
22267 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
22268 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
22269 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22270 </table>
22271 </div>
22272 </div>
22273
22274 <div class="ep-card">
22275 <div class="ep-header">
22276 <span class="method post">POST</span>
22277 <span class="ep-path">/webhooks/bitbucket</span>
22278 <span class="auth-badge hmac">HMAC</span>
22279 <span class="ep-desc">Bitbucket push event receiver</span>
22280 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22281 </div>
22282 <div class="ep-body">
22283 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
22284 <p class="params-heading">Required Headers</p>
22285 <table class="params">
22286 <tr><th>Header</th><th>Value</th></tr>
22287 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
22288 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22289 </table>
22290 </div>
22291 </div>
22292 </div>
22293
22294 <!-- Config -->
22295 <div class="section">
22296 <h2 class="section-title">Config Import / Export</h2>
22297
22298 <div class="ep-card">
22299 <div class="ep-header">
22300 <span class="method get">GET</span>
22301 <span class="ep-path">/export-config</span>
22302 <span class="auth-badge protected">Protected</span>
22303 <span class="ep-desc">Export server configuration as JSON</span>
22304 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22305 </div>
22306 <div class="ep-body">
22307 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
22308 <p class="curl-heading">Example</p>
22309 <div class="curl-wrap">
22310 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22311 -o config.json \
22312 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
22313 <button class="curl-copy-btn" data-target="c-export">Copy</button>
22314 </div>
22315 </div>
22316 </div>
22317
22318 <div class="ep-card">
22319 <div class="ep-header">
22320 <span class="method post">POST</span>
22321 <span class="ep-path">/import-config</span>
22322 <span class="auth-badge protected">Protected</span>
22323 <span class="ep-desc">Import server configuration</span>
22324 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22325 </div>
22326 <div class="ep-body">
22327 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
22328 <p class="curl-heading">Example</p>
22329 <div class="curl-wrap">
22330 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
22331 -H "Authorization: Bearer $SLOC_API_KEY" \
22332 -H "Content-Type: application/json" \
22333 -d @config.json \
22334 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
22335 <button class="curl-copy-btn" data-target="c-import">Copy</button>
22336 </div>
22337 </div>
22338 </div>
22339 </div>
22340
22341 <!-- CI Ingest -->
22342 <div class="section">
22343 <h2 class="section-title">CI Ingest</h2>
22344
22345 <div class="ep-card">
22346 <div class="ep-header">
22347 <span class="method post">POST</span>
22348 <span class="ep-path">/api/ingest</span>
22349 <span class="auth-badge protected">Protected</span>
22350 <span class="ep-desc">Push a pre-computed scan result from CI</span>
22351 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22352 </div>
22353 <div class="ep-body">
22354 <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>
22355 <p class="params-heading">Query Parameters</p>
22356 <table class="params">
22357 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22358 <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>
22359 </table>
22360 <p class="params-heading">Request Body (application/json)</p>
22361 <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>
22362 <details class="schema"><summary>Response schema</summary>
22363<div class="schema-block">// 201 Created
22364{
22365 "run_id": string, // UUID of the ingested run
22366 "view_url": string // relative URL to the report page
22367}</div></details>
22368 <p class="curl-heading">Example</p>
22369 <div class="curl-wrap">
22370 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
22371 -H "Authorization: Bearer $SLOC_API_KEY" \
22372 -H "Content-Type: application/json" \
22373 -d @result.json \
22374 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
22375 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
22376 </div>
22377 </div>
22378 </div>
22379 </div>
22380
22381 <!-- Artifact Download -->
22382 <div class="section">
22383 <h2 class="section-title">Artifact Download</h2>
22384
22385 <div class="ep-card">
22386 <div class="ep-header">
22387 <span class="method get">GET</span>
22388 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
22389 <span class="auth-badge protected">Protected</span>
22390 <span class="ep-desc">Download or view a scan artifact</span>
22391 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22392 </div>
22393 <div class="ep-body">
22394 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
22395 <p class="params-heading">Path Parameters</p>
22396 <table class="params">
22397 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22398 <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>
22399 <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>
22400 </table>
22401 <p class="params-heading">Query Parameters</p>
22402 <table class="params">
22403 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22404 <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>
22405 </table>
22406 <p class="curl-heading">Example — download JSON result</p>
22407 <div class="curl-wrap">
22408 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22409 -o result.json \
22410 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
22411 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
22412 </div>
22413 </div>
22414 </div>
22415 </div>
22416
22417 <!-- Embed Widget -->
22418 <div class="section">
22419 <h2 class="section-title">Embed Widget</h2>
22420
22421 <div class="ep-card">
22422 <div class="ep-header">
22423 <span class="method get">GET</span>
22424 <span class="ep-path">/embed/summary</span>
22425 <span class="auth-badge protected">Protected</span>
22426 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
22427 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22428 </div>
22429 <div class="ep-body">
22430 <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>
22431 <p class="params-heading">Query Parameters</p>
22432 <table class="params">
22433 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22434 <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>
22435 <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>
22436 </table>
22437 <p class="curl-heading">Example</p>
22438 <div class="curl-wrap">
22439 <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"
22440 width="460" height="260" style="border:none"></iframe></pre>
22441 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
22442 </div>
22443 </div>
22444 </div>
22445 </div>
22446
22447 <!-- Confluence Integration -->
22448 <div class="section">
22449 <h2 class="section-title">Confluence Integration</h2>
22450
22451 <div class="ep-card">
22452 <div class="ep-header">
22453 <span class="method get">GET</span>
22454 <span class="ep-path">/api/confluence/config</span>
22455 <span class="auth-badge protected">Protected</span>
22456 <span class="ep-desc">Get current Confluence configuration</span>
22457 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22458 </div>
22459 <div class="ep-body">
22460 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
22461 <details class="schema"><summary>Response schema</summary>
22462<div class="schema-block">{
22463 "configured": boolean,
22464 "tier": "cloud" | "server",
22465 "base_url": string,
22466 "username": string,
22467 "api_token_set": boolean,
22468 "space_key": string,
22469 "parent_page_id": string | null,
22470 "schedule_auto_post": { "<schedule_id>": boolean }
22471}</div></details>
22472 <p class="curl-heading">Example</p>
22473 <div class="curl-wrap">
22474 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22475 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22476 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
22477 </div>
22478 </div>
22479 </div>
22480
22481 <div class="ep-card">
22482 <div class="ep-header">
22483 <span class="method post">POST</span>
22484 <span class="ep-path">/api/confluence/config</span>
22485 <span class="auth-badge protected">Protected</span>
22486 <span class="ep-desc">Save Confluence configuration</span>
22487 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22488 </div>
22489 <div class="ep-body">
22490 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
22491 <p class="params-heading">Request Body (application/json)</p>
22492 <table class="params">
22493 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22494 <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>
22495 <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>
22496 <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>
22497 <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>
22498 <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>
22499 <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>
22500 <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>
22501 </table>
22502 <details class="schema"><summary>Response schema</summary>
22503<div class="schema-block">{ "ok": true }</div></details>
22504 <p class="curl-heading">Example</p>
22505 <div class="curl-wrap">
22506 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
22507 -H "Authorization: Bearer $SLOC_API_KEY" \
22508 -H "Content-Type: application/json" \
22509 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
22510 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22511 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
22512 </div>
22513 </div>
22514 </div>
22515
22516 <div class="ep-card">
22517 <div class="ep-header">
22518 <span class="method post">POST</span>
22519 <span class="ep-path">/api/confluence/test</span>
22520 <span class="auth-badge protected">Protected</span>
22521 <span class="ep-desc">Test Confluence connection</span>
22522 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22523 </div>
22524 <div class="ep-body">
22525 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
22526 <details class="schema"><summary>Response schema</summary>
22527<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
22528 <p class="curl-heading">Example</p>
22529 <div class="curl-wrap">
22530 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
22531 -H "Authorization: Bearer $SLOC_API_KEY" \
22532 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
22533 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
22534 </div>
22535 </div>
22536 </div>
22537
22538 <div class="ep-card">
22539 <div class="ep-header">
22540 <span class="method post">POST</span>
22541 <span class="ep-path">/api/confluence/post</span>
22542 <span class="auth-badge protected">Protected</span>
22543 <span class="ep-desc">Publish a scan report to Confluence</span>
22544 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22545 </div>
22546 <div class="ep-body">
22547 <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>
22548 <p class="params-heading">Request Body (application/json)</p>
22549 <table class="params">
22550 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22551 <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>
22552 <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>
22553 <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>
22554 </table>
22555 <details class="schema"><summary>Response schema</summary>
22556<div class="schema-block">// 200 OK
22557{ "ok": true, "page_id": string }
22558
22559// 400 / 502 on error
22560{ "ok": false, "error": string }</div></details>
22561 <p class="curl-heading">Example</p>
22562 <div class="curl-wrap">
22563 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
22564 -H "Authorization: Bearer $SLOC_API_KEY" \
22565 -H "Content-Type: application/json" \
22566 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
22567 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
22568 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
22569 </div>
22570 </div>
22571 </div>
22572
22573 <div class="ep-card">
22574 <div class="ep-header">
22575 <span class="method get">GET</span>
22576 <span class="ep-path">/api/confluence/wiki-markup</span>
22577 <span class="auth-badge protected">Protected</span>
22578 <span class="ep-desc">Get Confluence wiki markup for a run</span>
22579 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22580 </div>
22581 <div class="ep-body">
22582 <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>
22583 <p class="params-heading">Query Parameters</p>
22584 <table class="params">
22585 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22586 <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>
22587 </table>
22588 <p class="curl-heading">Example</p>
22589 <div class="curl-wrap">
22590 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22591 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
22592 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
22593 </div>
22594 </div>
22595 </div>
22596 </div>
22597
22598 <!-- Authentication -->
22599 <div class="section">
22600 <h2 class="section-title">Authentication</h2>
22601 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
22602
22603 <div class="ep-card">
22604 <div class="ep-header">
22605 <span class="method get">GET</span>
22606 <span class="ep-path">/auth/login</span>
22607 <span class="auth-badge public">Public</span>
22608 <span class="ep-desc">Login page</span>
22609 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22610 </div>
22611 <div class="ep-body">
22612 <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>
22613 <p class="params-heading">Query Parameters</p>
22614 <table class="params">
22615 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22616 <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>
22617 <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>
22618 </table>
22619 </div>
22620 </div>
22621
22622 <div class="ep-card">
22623 <div class="ep-header">
22624 <span class="method post">POST</span>
22625 <span class="ep-path">/auth/login</span>
22626 <span class="auth-badge public">Public</span>
22627 <span class="ep-desc">Submit credentials and get a session cookie</span>
22628 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22629 </div>
22630 <div class="ep-body">
22631 <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>
22632 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22633 <table class="params">
22634 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22635 <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>
22636 <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>
22637 </table>
22638 <p class="curl-heading">Example</p>
22639 <div class="curl-wrap">
22640 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22641 -d "key=$SLOC_API_KEY&next=/" \
22642 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22643 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22644 </div>
22645 </div>
22646 </div>
22647 </div>
22648
22649 <!-- Coverage Suggestion -->
22650 <div class="section">
22651 <h2 class="section-title">Coverage Suggestion</h2>
22652
22653 <div class="ep-card">
22654 <div class="ep-header">
22655 <span class="method get">GET</span>
22656 <span class="ep-path">/api/suggest-coverage</span>
22657 <span class="auth-badge protected">Protected</span>
22658 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22659 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22660 </div>
22661 <div class="ep-body">
22662 <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>
22663 <p class="params-heading">Query Parameters</p>
22664 <table class="params">
22665 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22666 <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>
22667 </table>
22668 <details class="schema"><summary>Response schema</summary>
22669<div class="schema-block">{
22670 "found": string | null, // absolute path to the coverage file, if detected
22671 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22672 "hint": string | null // shell command to generate coverage if not found
22673}</div></details>
22674 <p class="curl-heading">Example</p>
22675 <div class="curl-wrap">
22676 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22677 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22678 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22679 </div>
22680 </div>
22681 </div>
22682 </div>
22683
22684 </div>
22685
22686 <footer class="site-footer">
22687 local code analysis - metrics, history and reports
22688 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22689 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22690 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22691 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22692 · <a href="/api-docs" rel="noopener">REST API</a>
22693 </footer>
22694
22695 <script nonce="{{ csp_nonce }}">
22696 (function () {
22697 var base = window.location.origin;
22698 document.getElementById('base-url').textContent = base;
22699 document.querySelectorAll('.base-url-slot').forEach(function (el) {
22700 el.textContent = base;
22701 });
22702
22703 document.querySelectorAll('.ep-header').forEach(function (hdr) {
22704 hdr.addEventListener('click', function () {
22705 hdr.closest('.ep-card').classList.toggle('open');
22706 });
22707 });
22708
22709 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22710 btn.addEventListener('click', function () {
22711 var targetId = btn.dataset.target;
22712 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22713 if (!pre) return;
22714 navigator.clipboard.writeText(pre.textContent).then(function () {
22715 btn.textContent = 'Copied!';
22716 btn.classList.add('copied');
22717 setTimeout(function () {
22718 btn.textContent = 'Copy';
22719 btn.classList.remove('copied');
22720 }, 2000);
22721 });
22722 });
22723 });
22724
22725 var storageKey = 'oxide-sloc-theme';
22726 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22727 var themeBtn = document.getElementById('theme-toggle');
22728 if (themeBtn) {
22729 themeBtn.addEventListener('click', function () {
22730 var dark = document.body.classList.toggle('dark-theme');
22731 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22732 });
22733 }
22734 (function() {
22735 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'}];
22736 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);});}
22737 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22738 var btn=document.getElementById('settings-btn');if(!btn)return;
22739 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22740 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>';
22741 document.body.appendChild(m);
22742 var g=document.getElementById('scheme-grid');
22743 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);});
22744 var cl=document.getElementById('settings-close');
22745 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);
22746 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');});
22747 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22748 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22749 })();
22750 (function randomizeWatermarks() {
22751 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22752 if (!wms.length) return;
22753 var placed = [];
22754 function tooClose(top, left) {
22755 for (var i = 0; i < placed.length; i++) {
22756 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22757 if (dt < 16 && dl < 12) return true;
22758 }
22759 return false;
22760 }
22761 function pick(leftBand) {
22762 for (var attempt = 0; attempt < 50; attempt++) {
22763 var top = Math.random() * 88 + 2;
22764 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22765 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22766 }
22767 var top = Math.random() * 88 + 2;
22768 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22769 placed.push([top, left]); return [top, left];
22770 }
22771 var half = Math.floor(wms.length / 2);
22772 wms.forEach(function (img, i) {
22773 var pos = pick(i < half);
22774 var size = Math.floor(Math.random() * 100 + 120);
22775 var rot = (Math.random() * 360).toFixed(1);
22776 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22777 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;
22778 });
22779 })();
22780 (function spawnCodeParticles() {
22781 var container = document.getElementById('code-particles');
22782 if (!container) return;
22783 var snippets = [
22784 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22785 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22786 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22787 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22788 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22789 ];
22790 var count = 38;
22791 for (var i = 0; i < count; i++) {
22792 (function(idx) {
22793 var el = document.createElement('span');
22794 el.className = 'code-particle';
22795 el.textContent = snippets[idx % snippets.length];
22796 var left = Math.random() * 94 + 2;
22797 var top = Math.random() * 88 + 6;
22798 var dur = (Math.random() * 10 + 9).toFixed(1);
22799 var delay = (Math.random() * 18).toFixed(1);
22800 var rot = (Math.random() * 26 - 13).toFixed(1);
22801 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22802 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22803 container.appendChild(el);
22804 })(i);
22805 }
22806 })();
22807 }());
22808 </script>
22809</body>
22810</html>
22811"##,
22812 ext = "html"
22813)]
22814struct ApiDocsTemplate {
22815 has_api_key: bool,
22816 csp_nonce: String,
22817 version: &'static str,
22818}